diff --git a/.env.example b/.env.example index e5e876b..30cdf3d 100644 --- a/.env.example +++ b/.env.example @@ -153,6 +153,23 @@ BLOCK_INTERNAL_SSH=false # Leave empty unless a trusted bastion-only DNS name cannot be validated locally. # PROXY_JUMP_REMOTE_DNS_ALLOWLIST=bastion-only.internal +# Interactive SSH input resource limits. Byte values are hard-bounded by the +# application; defaults support normal typing and ordered large-paste chunking. +# SSH_INPUT_MAX_BYTES=131072 +# SSH_INPUT_SESSION_BURST_BYTES=262144 +# SSH_INPUT_SESSION_BYTES_PER_SECOND=262144 +# SSH_INPUT_USER_BURST_BYTES=524288 +# SSH_INPUT_USER_BYTES_PER_SECOND=524288 + +# Per-user persistent command-library limits. Existing oversized legacy stores +# remain readable and can be reduced, but new mutations cannot grow them. +# COMMAND_MAX_RECORDS=500 +# COMMAND_SET_MAX_RECORDS=500 +# COMMAND_SET_MAX_STEPS=64 +# COMMAND_OS_MAX_ENTRIES=16 +# COMMAND_STORE_MAX_BYTES=2097152 +# COMMAND_CONFIG_MAX_BYTES=4194304 + # Optional SMB file sources. Disabled by default. When enabled, list every # permitted server as an exact hostname or IP; TCP 445, SMB 3.1.1, signing and # encryption remain mandatory. Credentials are entered per connection only. @@ -192,6 +209,8 @@ RATELIMIT_DEFAULT=200 per hour RATELIMIT_REAUTH=5 per minute # Per-user SSH and quick-connect attempt rate. SSH_CONNECT_RATELIMIT=10 per minute +# Per-user command and command-set mutation rate. +# COMMAND_MUTATION_RATELIMIT=60 per minute # Storage backend for rate-limit counters. # memory:// (default) — per-process, no external dependency. # redis://host:port/db — counters survive app restarts while Redis is running. diff --git a/app/__init__.py b/app/__init__.py index c52758e..7dde884 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -488,6 +488,9 @@ def index(): 'archiveBytes': config.MAX_ZIP_DOWNLOAD_SIZE, 'remoteTransferBytes': config.MAX_ZIP_DOWNLOAD_SIZE, }, + ssh_input_limits={ + 'maxEventBytes': config.SSH_INPUT_MAX_BYTES, + }, ) @app.route('/login', methods=['GET', 'POST']) @@ -659,6 +662,12 @@ def logout(): @app.route('/change-password', methods=['GET', 'POST']) @login_required def change_password(): + from . import user_lifecycle + from .auth_assurance import ( + AuthenticationFinalizationError, + invalidate_user_authentication, + ) + if current_user.is_ldap_managed or current_user.is_github_managed: abort(403) if request.method == 'POST': @@ -696,9 +705,34 @@ def change_password(): elif current_user.check_password(new_password): flash('New password must be different from current password', 'error') else: - current_user.set_password(new_password) - db.session.commit() - log_password_change(current_user.username, True, get_client_ip()) + password_owner = current_user._get_current_object() + password_owner.set_password(new_password) + remember_cookie_name = app.config.get( + 'REMEMBER_COOKIE_NAME', + 'remember_token', + ) + try: + invalidate_user_authentication( + password_owner, + preserve_current_browser=True, + remember=bool(request.cookies.get(remember_cookie_name)), + ) + db.session.commit() + except AuthenticationFinalizationError as exc: + db.session.rollback() + clear_browser_authentication() + log_warning( + 'Password change session rotation failed', + user=password_owner.username, + error=type(exc).__name__, + ) + abort(500) + user_lifecycle.revoke_user_access(password_owner.id, socketio) + log_password_change( + password_owner.username, + True, + get_client_ip(), + ) flash('Password updated successfully', 'success') return redirect(url_for('index')) settings = get_user_settings(current_user.id) @@ -879,6 +913,7 @@ def admin_create_user(): ) def admin_user_action(user_id, action): from . import user_lifecycle + from .auth_assurance import invalidate_user_authentication target = db.session.get(User, user_id) if not target: @@ -893,6 +928,7 @@ def _is_last_admin(): if is_self: return jsonify({'error': 'You cannot lock your own account'}), 400 target.is_locked = True + invalidate_user_authentication(target) revoke_after_commit = True elif action == 'unlock': target.is_locked = False @@ -925,6 +961,7 @@ def _is_last_admin(): return jsonify({'error': 'Cannot delete the last administrator'}), 400 username = target.username target.is_locked = True + invalidate_user_authentication(target) db.session.commit() try: user_lifecycle.delete_user_account(target, socketio) diff --git a/app/auth_assurance.py b/app/auth_assurance.py index 43e2b82..e3253ca 100644 --- a/app/auth_assurance.py +++ b/app/auth_assurance.py @@ -17,10 +17,16 @@ from .audit_logger import log_security_event from .models import ( AuthenticationSession, + GitHubOAuthState, + OIDCLoginState, PendingAuthentication, RecoveryCode, + StepUpGrant, + StepUpIntent, TOTPAuthenticator, + TOTPEnrollment, User, + WebAuthnChallenge, as_naive_utc, db, ) @@ -268,6 +274,108 @@ def clear_browser_authentication(): session['_remember'] = 'clear' +def invalidate_user_authentication( + user, + *, + preserve_current_browser=False, + remember=False, +): + """Invalidate every browser credential derived from a user's old state. + + When ``preserve_current_browser`` is true, the caller's current valid + assurance is moved to one new opaque session under the incremented + generation. The caller must commit the surrounding security-state change. + """ + if not isinstance(user, User) or user.id is None: + raise TypeError('user must be persistent') + + replacement = None + if preserve_current_browser: + current = current_authentication_session() + if current is None or current.user_id != user.id: + raise AuthenticationFinalizationError( + 'current authentication session is unavailable' + ) + replacement = { + 'assurance': current.assurance, + 'methods_json': current.methods_json, + 'authenticated_at': current.authenticated_at, + 'strong_authenticated_at': current.strong_authenticated_at, + 'expires_at': current.expires_at, + } + + authentication_session_ids = [ + row.id + for row in AuthenticationSession.query.with_entities( + AuthenticationSession.id + ).filter_by(user_id=user.id) + ] + step_up_intent_ids = [ + row.id + for row in StepUpIntent.query.with_entities( + StepUpIntent.id + ).filter_by(user_id=user.id) + ] + + GitHubOAuthState.query.filter_by(user_id=user.id).delete( + synchronize_session=False + ) + if step_up_intent_ids: + GitHubOAuthState.query.filter( + GitHubOAuthState.step_up_intent_id.in_(step_up_intent_ids) + ).delete(synchronize_session=False) + OIDCLoginState.query.filter( + OIDCLoginState.step_up_intent_id.in_(step_up_intent_ids) + ).delete(synchronize_session=False) + if authentication_session_ids: + StepUpGrant.query.filter( + StepUpGrant.authentication_session_id.in_( + authentication_session_ids + ) + ).delete(synchronize_session=False) + StepUpIntent.query.filter_by(user_id=user.id).delete( + synchronize_session=False + ) + AuthenticationSession.query.filter_by(user_id=user.id).delete( + synchronize_session=False + ) + PendingAuthentication.query.filter_by(user_id=user.id).delete( + synchronize_session=False + ) + WebAuthnChallenge.query.filter_by(user_id=user.id).delete( + synchronize_session=False + ) + TOTPEnrollment.query.filter_by(user_id=user.id).delete( + synchronize_session=False + ) + + user.auth_generation = int(user.auth_generation or 0) + 1 + if replacement is None: + return None + + opaque_session_id = secrets.token_urlsafe(32) + row = AuthenticationSession( + session_hash=_digest(opaque_session_id), + user_id=user.id, + assurance=replacement['assurance'], + methods_json=replacement['methods_json'], + authenticated_at=replacement['authenticated_at'], + strong_authenticated_at=replacement['strong_authenticated_at'], + auth_generation=int(user.auth_generation or 0), + expires_at=replacement['expires_at'], + ) + + session.clear() + login_user(user, remember=bool(remember)) + session['_user_id'] = ( + f'{user.id}:{int(user.auth_generation or 0)}:{opaque_session_id}' + ) + session['_auth_session'] = opaque_session_id + session['_auth_epoch'] = current_epoch() + db.session.add(row) + return row + + def finalize_login(pending, *, methods, strong_authenticated_at=None): """Create the sole authenticated browser-session record.""" if not isinstance(pending, PendingAuthentication): diff --git a/app/cli.py b/app/cli.py index d2e78ab..9a6688f 100644 --- a/app/cli.py +++ b/app/cli.py @@ -277,6 +277,8 @@ def backup_restore(archive, confirm_offline): """Restore a verified backup while WebSSH is stopped.""" from .backup_coordination import operation_lock from .backup_manager import restore_backup + from .restore_sanitizer import sanitize_restored_authentication_state + from .session_epoch import reset_cache, rotate_epoch _require_offline_confirmation(confirm_offline) try: @@ -284,6 +286,11 @@ def backup_restore(archive, confirm_offline): db.session.remove() db.engine.dispose() restore_backup(archive, config.DATA_DIR) + sanitize_restored_authentication_state( + Path(config.DATA_DIR) / 'app.db' + ) + reset_cache() + rotate_epoch() except Exception as exc: raise click.ClickException(str(exc)) from exc from .maintenance_mode import clear_failed_status_after_cli_restore diff --git a/app/command_manager.py b/app/command_manager.py index 1222278..62315b9 100644 --- a/app/command_manager.py +++ b/app/command_manager.py @@ -7,6 +7,10 @@ from datetime import datetime, timezone import config from .audit_logger import log_error +from .command_storage_policy import ( + enforce_store_transition, + validate_user_command, +) from .storage_utils import atomic_write_json, load_json_strict, storage_lock from .storage_migrations import backup_before_migration @@ -121,11 +125,26 @@ def _load_user_commands_for_write(user_id): return None, 'User not found' return _load_user_commands_with_lock_held(user_id), None -def save_user_commands(user_id, commands): +def save_user_commands( + user_id, + commands, + *, + previous_size=None, + previous_count=None, +): """Save user-specific commands.""" user_commands_file = get_user_commands_file(user_id) if not user_commands_file or not _valid_commands(commands): return False + enforce_store_transition( + path=user_commands_file, + prospective_document=commands, + prospective_count=len(commands), + maximum_count=config.COMMAND_MAX_RECORDS, + other_path=user_commands_file.parent / 'command_sets.json', + previous_size=previous_size, + previous_count=previous_count, + ) user_commands_file.parent.mkdir(parents=True, exist_ok=True) atomic_write_json(user_commands_file, commands) return True @@ -194,14 +213,20 @@ def add_user_command(user_id, name, command, parameters, description, os_list, c tzinfo=None ).isoformat() } + validate_user_command(new_cmd) with storage_lock(f'command-config:{user_id}'): with storage_lock(f'commands:{user_id}'): user_cmds, error = _load_user_commands_for_write(user_id) if error: return None + previous_count = len(user_cmds) user_cmds.append(new_cmd) - return new_cmd if save_user_commands(user_id, user_cmds) else None + return new_cmd if save_user_commands( + user_id, + user_cmds, + previous_count=previous_count, + ) else None def update_user_command(user_id, command_id, name, command, parameters, description, os_list, category): """Update an existing user command.""" @@ -217,18 +242,28 @@ def update_user_command(user_id, command_id, name, command, parameters, descript for cmd in user_cmds: if cmd['id'] == command_id: - cmd['name'] = name - cmd['command'] = command - cmd['parameters'] = parameters or '' - cmd['description'] = description - cmd['os'] = os_list - cmd['category'] = category or 'custom' + updated = { + **cmd, + 'name': name, + 'command': command, + 'parameters': parameters or '', + 'description': description, + 'os': os_list, + 'category': category or 'custom', + } + validate_user_command(updated, legacy=cmd) + cmd.clear() + cmd.update(updated) break else: return None, 'Command not found' try: - saved = save_user_commands(user_id, user_cmds) + saved = save_user_commands( + user_id, + user_cmds, + previous_count=len(user_cmds), + ) except OSError as exc: log_error( 'Error saving user command', @@ -267,6 +302,10 @@ def delete_user_command(user_id, command_id): remaining = [cmd for cmd in user_cmds if cmd.get('id') != command_id] if len(remaining) == len(user_cmds): return False, 'Command not found', [] - if not save_user_commands(user_id, remaining): + if not save_user_commands( + user_id, + remaining, + previous_count=len(user_cmds), + ): return False, 'Failed to delete command', [] return True, None, [] diff --git a/app/command_set_manager.py b/app/command_set_manager.py index c279752..dde4d01 100644 --- a/app/command_set_manager.py +++ b/app/command_set_manager.py @@ -3,7 +3,14 @@ import uuid from datetime import datetime, timezone +import config + from .audit_logger import log_error +from .command_storage_policy import ( + CommandStorageLimitError, + enforce_store_transition, + validate_command_set, +) from .startup_commands import normalize_startup_commands from .storage_utils import ( atomic_write_json, @@ -249,7 +256,13 @@ def load_command_sets(user_id): return _load_command_sets_with_lock_held(user_id) -def _save_command_sets(user_id, command_sets): +def _save_command_sets( + user_id, + command_sets, + *, + previous_size=None, + previous_count=None, +): path = _command_sets_file(user_id) if path is None: return False, 'User not found' @@ -260,9 +273,20 @@ def _save_command_sets(user_id, command_sets): if not _valid_command_set_document(document): return False, 'Invalid command set data' try: + enforce_store_transition( + path=path, + prospective_document=document, + prospective_count=len(command_sets), + maximum_count=config.COMMAND_SET_MAX_RECORDS, + other_path=path.parent / 'commands.json', + previous_size=previous_size, + previous_count=previous_count, + ) path.parent.mkdir(parents=True, exist_ok=True) atomic_write_json(path, document) return True, None + except CommandStorageLimitError as exc: + return False, str(exc) except OSError as exc: log_error('Error saving command sets', user_id=user_id, error=str(exc)) return False, 'Failed to save command sets' @@ -364,7 +388,29 @@ def _validate_payload(payload, existing_sets, commands): if existing.get('id') != command_set_id and str(existing.get('name', '')).casefold() == name.casefold(): return None, 'A command set with this name already exists' - steps, _resolved_parts, error = _normalize_steps(payload.get('steps'), commands) + legacy = next( + ( + item for item in existing_sets + if item.get('id') == command_set_id + ), + None, + ) + try: + validate_command_set( + { + 'id': command_set_id or '', + 'name': name, + 'description': description.strip(), + 'steps': payload.get('steps'), + }, + legacy=legacy, + ) + except CommandStorageLimitError as exc: + return None, str(exc) + + steps, _resolved_parts, error = _normalize_steps( + payload.get('steps'), commands + ) if error: return None, error return { @@ -417,6 +463,7 @@ def upsert_command_set(user_id, payload): ) if error: return None, error + previous_count = len(command_sets) now = datetime.now(timezone.utc).isoformat() command_set_id = validated.get('id') @@ -448,7 +495,11 @@ def upsert_command_set(user_id, payload): } command_sets.append(result) - saved, error = _save_command_sets(user_id, command_sets) + saved, error = _save_command_sets( + user_id, + command_sets, + previous_count=previous_count, + ) return (copy.deepcopy(result), None) if saved else (None, error) @@ -480,8 +531,17 @@ def duplicate_command_set(user_id, command_set_id): 'created_at': now, 'updated_at': now, } + try: + validate_command_set(duplicate) + except CommandStorageLimitError as exc: + return None, str(exc) + previous_count = len(command_sets) command_sets.append(duplicate) - saved, error = _save_command_sets(user_id, command_sets) + saved, error = _save_command_sets( + user_id, + command_sets, + previous_count=previous_count, + ) return (copy.deepcopy(duplicate), None) if saved else (None, error) @@ -643,5 +703,9 @@ def delete_command_set(user_id, command_set_id): remaining = [item for item in command_sets if item.get('id') != command_set_id] if len(remaining) == len(command_sets): return False, 'Command set not found', [] - saved, error = _save_command_sets(user_id, remaining) + saved, error = _save_command_sets( + user_id, + remaining, + previous_count=len(command_sets), + ) return saved, error, [] diff --git a/app/command_storage_policy.py b/app/command_storage_policy.py new file mode 100644 index 0000000..a90ae05 --- /dev/null +++ b/app/command_storage_policy.py @@ -0,0 +1,218 @@ +"""Prospective resource policy for per-user command configuration.""" + +import json +from pathlib import Path + +import config + + +COMMAND_NAME_MAX_BYTES = 512 +COMMAND_TEXT_MAX_BYTES = 16 * 1024 +COMMAND_PARAMETERS_MAX_BYTES = 16 * 1024 +COMMAND_DESCRIPTION_MAX_BYTES = 16 * 1024 +COMMAND_CATEGORY_MAX_BYTES = 256 +COMMAND_OS_VALUE_MAX_BYTES = 128 +COMMAND_ID_MAX_BYTES = 128 + + +class CommandStorageLimitError(ValueError): + """A command mutation would exceed a persistent resource boundary.""" + + +def _error(message): + raise CommandStorageLimitError( + f'Command storage quota exceeded: {message}' + ) + + +def utf8_size(value): + try: + return len(value.encode('utf-8')) + except UnicodeEncodeError: + _error('text is not valid UTF-8') + + +def serialized_size(value): + try: + return len(json.dumps(value, indent=2).encode('utf-8')) + except (TypeError, ValueError, UnicodeEncodeError) as exc: + raise CommandStorageLimitError( + 'Command storage quota exceeded: data is not serializable' + ) from exc + + +def _bounded_text(value, limit, label, legacy=None): + if not isinstance(value, str): + _error(f'{label} must be text') + size = utf8_size(value) + if size <= limit: + return + if isinstance(legacy, str) and size <= utf8_size(legacy): + return + _error(f'{label} is too large') + + +def validate_user_command(command, legacy=None): + legacy = legacy if isinstance(legacy, dict) else {} + _bounded_text( + command.get('id', ''), + COMMAND_ID_MAX_BYTES, + 'command id', + legacy.get('id'), + ) + for field, limit, label in ( + ('name', COMMAND_NAME_MAX_BYTES, 'command name'), + ('command', COMMAND_TEXT_MAX_BYTES, 'command text'), + ('parameters', COMMAND_PARAMETERS_MAX_BYTES, 'command parameters'), + ('description', COMMAND_DESCRIPTION_MAX_BYTES, 'command description'), + ('category', COMMAND_CATEGORY_MAX_BYTES, 'command category'), + ): + _bounded_text( + command.get(field, ''), + limit, + label, + legacy.get(field), + ) + os_values = command.get('os', []) + legacy_os = legacy.get('os', []) + if not isinstance(os_values, list): + _error('command operating systems must be a list') + if ( + len(os_values) > config.COMMAND_OS_MAX_ENTRIES + and len(os_values) > len(legacy_os if isinstance(legacy_os, list) else []) + ): + _error('too many command operating systems') + for index, value in enumerate(os_values): + previous = ( + legacy_os[index] + if isinstance(legacy_os, list) and index < len(legacy_os) + else None + ) + _bounded_text( + value, + COMMAND_OS_VALUE_MAX_BYTES, + 'command operating system', + previous, + ) + + +def validate_command_set(command_set, legacy=None): + legacy = legacy if isinstance(legacy, dict) else {} + for field, limit, label in ( + ('id', COMMAND_ID_MAX_BYTES, 'command set id'), + ('name', COMMAND_NAME_MAX_BYTES, 'command set name'), + ( + 'description', + COMMAND_DESCRIPTION_MAX_BYTES, + 'command set description', + ), + ): + _bounded_text( + command_set.get(field, ''), + limit, + label, + legacy.get(field), + ) + steps = command_set.get('steps', []) + legacy_steps = legacy.get('steps', []) + if not isinstance(steps, list): + _error('command set steps must be a list') + if ( + len(steps) > config.COMMAND_SET_MAX_STEPS + and len(steps) > len( + legacy_steps if isinstance(legacy_steps, list) else [] + ) + ): + _error('too many command set steps') + for index, step in enumerate(steps): + previous = ( + legacy_steps[index] + if isinstance(legacy_steps, list) + and index < len(legacy_steps) + and isinstance(legacy_steps[index], dict) + else {} + ) + if not isinstance(step, dict): + _error('command set step is invalid') + if step.get('type') == 'inline': + _bounded_text( + step.get('command', ''), + COMMAND_TEXT_MAX_BYTES, + 'inline command', + previous.get('command'), + ) + elif step.get('type') == 'library': + _bounded_text( + step.get('command_id', ''), + COMMAND_ID_MAX_BYTES, + 'command reference', + previous.get('command_id'), + ) + override = step.get('parameters_override') + if override is not None: + _bounded_text( + override, + COMMAND_PARAMETERS_MAX_BYTES, + 'command parameter override', + previous.get('parameters_override'), + ) + + +def _file_size(path): + try: + return Path(path).stat().st_size + except FileNotFoundError: + return 0 + + +def enforce_store_transition( + *, + path, + prospective_document, + prospective_count, + maximum_count, + other_path, + previous_size=None, + previous_count=None, +): + """Reject only new growth beyond count, document, or aggregate quotas.""" + path = Path(path) + current_size = ( + _file_size(path) if previous_size is None else int(previous_size) + ) + current_count = ( + 0 + if previous_count is None + else int(previous_count) + ) + prospective_size = serialized_size(prospective_document) + if ( + prospective_count > maximum_count + and prospective_count > current_count + ): + _error(f'more than {maximum_count} records are not allowed') + if ( + prospective_size > config.COMMAND_STORE_MAX_BYTES + and prospective_size > current_size + ): + _error('one command store would exceed its byte limit') + other_size = _file_size(other_path) + if ( + prospective_size + other_size > config.COMMAND_CONFIG_MAX_BYTES + and prospective_size + other_size > current_size + other_size + ): + _error('combined command data would exceed its byte limit') + return prospective_size + + +def command_storage_usage(user_data_dir): + root = Path(user_data_dir) + commands = _file_size(root / 'commands.json') + command_sets = _file_size(root / 'command_sets.json') + return { + 'commands_bytes': commands, + 'command_sets_bytes': command_sets, + 'total_bytes': commands + command_sets, + 'quota_bytes': config.COMMAND_CONFIG_MAX_BYTES, + 'over_quota': commands + command_sets > config.COMMAND_CONFIG_MAX_BYTES, + } diff --git a/app/maintenance_mode.py b/app/maintenance_mode.py index c00e970..943ce47 100644 --- a/app/maintenance_mode.py +++ b/app/maintenance_mode.py @@ -216,10 +216,14 @@ def recover_interrupted_restore() -> None: rollback = ensure_backup_temp_dir() / Path(*relative.parts) try: from .backup_manager import restore_backup + from .restore_sanitizer import sanitize_restored_authentication_state from .session_epoch import reset_cache, rotate_epoch with operation_lock(): restore_backup(rollback, config.DATA_DIR) + sanitize_restored_authentication_state( + Path(config.DATA_DIR) / 'app.db' + ) reset_cache() rotate_epoch() mark_failed(operation_id, 'Interrupted restore rolled back automatically') diff --git a/app/network_policy.py b/app/network_policy.py index 5d42f5e..375bb95 100644 --- a/app/network_policy.py +++ b/app/network_policy.py @@ -62,10 +62,12 @@ def canonicalize_hostname(hostname): def _ip_is_internal(address): return ( - address.is_loopback + not address.is_global + or address.is_loopback or address.is_link_local or address.is_private or address.is_reserved + or getattr(address, 'is_site_local', False) or address.is_multicast or address.is_unspecified ) diff --git a/app/restore_sanitizer.py b/app/restore_sanitizer.py new file mode 100644 index 0000000..1907c77 --- /dev/null +++ b/app/restore_sanitizer.py @@ -0,0 +1,63 @@ +"""Fail-closed cleanup for replayable state after persistent-state restore.""" + +from pathlib import Path +import sqlite3 + + +_TRANSIENT_TABLES = ( + 'github_oauth_states', + 'oidc_login_states', + 'step_up_grants', + 'step_up_intents', + 'authentication_sessions', + 'pending_authentications', + 'webauthn_challenges', + 'totp_enrollments', + 'socket_sessions', + 'ssh_sessions', +) + + +def sanitize_restored_authentication_state(database_path: Path): + """Remove replayable runtime credentials from one restored database. + + Older compatible backups may not contain every current transient table, + so the sanitizer deletes the intersection present in the restored schema. + Durable users, provider identities, MFA authenticators, and recovery codes + are intentionally preserved. + """ + connection = sqlite3.connect(str(database_path), timeout=30) + try: + connection.execute('BEGIN IMMEDIATE') + present = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table'" + ) + } + deleted = {} + for table in _TRANSIENT_TABLES: + if table not in present: + continue + cursor = connection.execute(f'DELETE FROM "{table}"') + deleted[table] = max(0, int(cursor.rowcount or 0)) + user_columns = ( + { + row[1] + for row in connection.execute('PRAGMA table_info("users")') + } + if 'users' in present + else set() + ) + if 'auth_generation' in user_columns: + connection.execute( + 'UPDATE users SET auth_generation = ' + 'COALESCE(auth_generation, 0) + 1' + ) + connection.commit() + return deleted + except Exception: + connection.rollback() + raise + finally: + connection.close() diff --git a/app/restore_service.py b/app/restore_service.py index a7a0141..4f5f1fb 100644 --- a/app/restore_service.py +++ b/app/restore_service.py @@ -3,7 +3,6 @@ import os from pathlib import Path import signal -import sqlite3 import threading import time @@ -22,6 +21,7 @@ mark_succeeded, ) from .session_epoch import reset_cache, rotate_epoch +from .restore_sanitizer import sanitize_restored_authentication_state def _close_active_ssh_sessions(): @@ -49,13 +49,8 @@ def _disconnect_sockets(socketio): def _clear_restored_runtime_sessions(database_path: Path): - connection = sqlite3.connect(str(database_path), timeout=30) - try: - connection.execute('DELETE FROM socket_sessions') - connection.execute('DELETE FROM ssh_sessions') - connection.commit() - finally: - connection.close() + """Compatibility wrapper for the complete post-restore sanitizer.""" + return sanitize_restored_authentication_state(database_path) def request_process_restart(delay=1.0): @@ -106,9 +101,9 @@ def _perform_restore(app, socketio, record, username, source_ip, mark_in_progress(operation_id, relative) restore_backup(record.archive_path, config.DATA_DIR) + _clear_restored_runtime_sessions(Path(config.DATA_DIR) / 'app.db') reset_cache() rotate_epoch() - _clear_restored_runtime_sessions(Path(config.DATA_DIR) / 'app.db') mark_succeeded(operation_id) log_security_event( 'RESTORE_SUCCEEDED', user=username, ip=source_ip @@ -118,9 +113,11 @@ def _perform_restore(app, socketio, record, username, source_ip, if rollback_available: try: restore_backup(rollback_archive, config.DATA_DIR) + _clear_restored_runtime_sessions( + Path(config.DATA_DIR) / 'app.db' + ) reset_cache() rotate_epoch() - _clear_restored_runtime_sessions(Path(config.DATA_DIR) / 'app.db') restart_required = True except Exception: rollback_failed = True diff --git a/app/socket_events.py b/app/socket_events.py index 023f3f4..79f135e 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -20,16 +20,19 @@ log_key_delete, log_tailscale_ssh_usage) from .tailscale_ssh import ( + authorize_tailscale_ssh_access, profile_is_authorized_for_launch, validate_tailscale_ssh_access, ) from .storage_errors import StorageCorruptionError +from .command_storage_policy import CommandStorageLimitError from .network_policy import canonicalize_hostname from .ssh_errors import connection_error_payload from . import binary_transfer, connection_pool from .transfer_routes import prepare_transfer, transfer_manager, _terminalize from .quota_manager import QuotaKind, quota_manager from .socket_capacity import socket_capacity +from .ssh_input_budget import budget_from_config from .remote_transfer import ( RemoteTransferCancelled, RemoteTransferError, @@ -82,6 +85,7 @@ _ssh_banner_prompts_lock = threading.RLock() _ssh_banner_prompts = {} SSH_AUTH_BANNER_DECISION_TIMEOUT = 60 +_ssh_input_budget = budget_from_config(config) def _new_smb_diagnostic_id(): @@ -678,8 +682,15 @@ def request_auth_banner_decision(banner, context): emit_error('Invalid authentication method') return + tailscale_authorization = None if auth_type == 'tailscale': - access_error = validate_tailscale_ssh_access(current_user, host, username) + tailscale_authorization, access_error = ( + authorize_tailscale_ssh_access( + current_user, + host, + username, + ) + ) log_tailscale_ssh_usage( current_user.username, host, port, username, request.remote_addr, allowed=access_error is None, error=access_error @@ -778,6 +789,7 @@ def connect_ssh(cancel_event, credentials=credential_box): '' if reconnect_tmux_name else startup_commands ), auth_banner_decision=request_auth_banner_decision, + tailscale_authorization=tailscale_authorization, ) if error: @@ -938,26 +950,93 @@ def connect_ssh(cancel_event, credentials=credential_box): def handle_ssh_input(data, current_user=None): """Handle user input to SSH session.""" try: + data = data if isinstance(data, dict) else {} + allowed_fields = { + 'session_id', + 'data', + 'acknowledge_backpressure', + } + if any(field not in allowed_fields for field in data): + return {'success': False, 'error': 'Invalid SSH input'} session_id = data.get('session_id') input_data = data.get('data') + acknowledge_backpressure = data.get( + 'acknowledge_backpressure', False + ) - if not session_id or input_data is None: - return + if ( + not isinstance(session_id, str) + or not 0 < len(session_id) <= 128 + or input_data is None + or type(acknowledge_backpressure) is not bool + ): + return {'success': False, 'error': 'Invalid SSH input'} + + if not isinstance(input_data, str): + return {'success': False, 'error': 'Invalid SSH input'} + + if len(input_data) > config.SSH_INPUT_MAX_BYTES: + input_bytes = config.SSH_INPUT_MAX_BYTES + 1 + else: + try: + input_bytes = len(input_data.encode('utf-8')) + except UnicodeEncodeError: + input_bytes = config.SSH_INPUT_MAX_BYTES + 1 + if input_bytes > config.SSH_INPUT_MAX_BYTES: + payload = { + 'success': False, + 'error': 'SSH input is too large', + 'code': 'ssh_input_too_large', + 'session_id': session_id, + } + emit('ssh_error', payload) + return payload if not verify_session_ownership(session_id, current_user.id): - emit('ssh_error', {'error': 'Unauthorized access to session', 'session_id': session_id}) - return + payload = { + 'success': False, + 'error': 'Unauthorized access to session', + 'session_id': session_id, + } + emit('ssh_error', payload) + return payload - if not isinstance(input_data, str): - return + allowed, retry_after_ms = _ssh_input_budget.allow( + current_user.id, + session_id, + input_bytes, + ) + if not allowed: + payload = { + 'success': False, + 'error': 'SSH input is temporarily rate limited', + 'code': 'ssh_input_backpressure', + 'retry_after_ms': retry_after_ms, + 'session_id': session_id, + } + if not acknowledge_backpressure: + emit('ssh_error', payload) + return payload - success, error = ssh_manager.send_ssh_input(session_id, input_data) + success, error = ssh_manager.send_ssh_input( + session_id, + input_data, + require_complete=True, + ) if error: - emit('ssh_error', {'error': error, 'session_id': session_id}) + payload = { + 'success': False, + 'error': error, + 'session_id': session_id, + } + emit('ssh_error', payload) + return payload + return {'success': bool(success)} except Exception as e: log_error("SSH input error", error=str(e)) emit('ssh_error', {'error': 'Input error'}) + return {'success': False, 'error': 'Input error'} @socketio.on('keep_alive') @socket_login_required @@ -1715,6 +1794,9 @@ def handle_list_commands(data, current_user=None): @socket_login_required def handle_add_command(data, current_user=None): """Add a new user command.""" + limited = _command_mutation_rate_limit(current_user) + if limited: + return limited try: from . import command_manager @@ -1745,6 +1827,8 @@ def handle_add_command(data, current_user=None): handle_list_commands({}, current_user=current_user) return {'success': True, 'command': new_cmd} + except CommandStorageLimitError as error: + return _command_set_error(str(error)) except StorageCorruptionError as error: return _emit_storage_error(error, current_user) except Exception as e: @@ -1756,6 +1840,9 @@ def handle_add_command(data, current_user=None): @socket_login_required def handle_update_command(data, current_user=None): """Update an existing user command.""" + limited = _command_mutation_rate_limit(current_user) + if limited: + return limited try: from . import command_manager @@ -1787,6 +1874,8 @@ def handle_update_command(data, current_user=None): handle_list_commands({}, current_user=current_user) return payload + except CommandStorageLimitError as error: + return _command_set_error(str(error)) except StorageCorruptionError as storage_error: return _emit_storage_error(storage_error, current_user) except Exception as e: @@ -1797,6 +1886,9 @@ def handle_update_command(data, current_user=None): @socket_login_required def handle_delete_command(data, current_user=None): """Delete a user command.""" + limited = _command_mutation_rate_limit(current_user) + if limited: + return limited try: from . import command_manager @@ -1833,6 +1925,21 @@ def handle_delete_command(data, current_user=None): emit('error', {'error': 'Failed to delete command'}) +_COMMAND_MUTATION_RATE_ERROR = ( + 'Too many command changes. Please wait before trying again.' +) + + +def _command_mutation_rate_limit(current_user): + if config.RATELIMIT_ENABLED and check_socket_rate_limit( + current_user.id, + 'command_mutation', + config.RATELIMIT_COMMAND_MUTATION, + ): + return _command_set_error(_COMMAND_MUTATION_RATE_ERROR) + return None + + def _command_set_error(error, usages=None): if usages: code = 'in_use' @@ -1843,6 +1950,10 @@ def _command_set_error(error, usages=None): 'Jump host not found', ): code = 'not_found' + elif error == _COMMAND_MUTATION_RATE_ERROR: + code = 'rate_limited' + elif error and error.startswith('Command storage quota exceeded:'): + code = 'quota_exceeded' elif error and 'unreadable' in error: code = 'storage_error' else: @@ -1879,6 +1990,9 @@ def handle_save_command_set(data, current_user=None): """Create or update a named command set.""" from . import command_set_manager + limited = _command_mutation_rate_limit(current_user) + if limited: + return limited try: command_set, error = command_set_manager.upsert_command_set( current_user.id, data @@ -1899,6 +2013,9 @@ def handle_duplicate_command_set(data, current_user=None): """Duplicate one of the current user's command sets.""" from . import command_set_manager + limited = _command_mutation_rate_limit(current_user) + if limited: + return limited data = data if isinstance(data, dict) else {} try: command_set, error = command_set_manager.duplicate_command_set( @@ -1920,6 +2037,9 @@ def handle_delete_command_set(data, current_user=None): """Delete an unused command set.""" from . import command_set_manager + limited = _command_mutation_rate_limit(current_user) + if limited: + return limited data = data if isinstance(data, dict) else {} command_set_id = data.get('command_set_id') try: @@ -1942,6 +2062,9 @@ def handle_convert_legacy_command_set(data, current_user=None): """Convert one profile's legacy startup text into a named command set.""" from . import command_set_manager + limited = _command_mutation_rate_limit(current_user) + if limited: + return limited data = data if isinstance(data, dict) else {} try: profile = profile_manager.get_profile( diff --git a/app/ssh_input_budget.py b/app/ssh_input_budget.py new file mode 100644 index 0000000..a9aee3c --- /dev/null +++ b/app/ssh_input_budget.py @@ -0,0 +1,106 @@ +"""Bound per-session and per-user interactive SSH input throughput.""" + +from dataclasses import dataclass +import math +from threading import RLock +import time + + +@dataclass +class _Bucket: + tokens: float + updated_at: float + + +class SSHInputBudget: + """Two-level token bucket for the documented single-worker runtime.""" + + def __init__( + self, + *, + session_capacity, + session_rate, + user_capacity, + user_rate, + ): + self.session_capacity = int(session_capacity) + self.session_rate = int(session_rate) + self.user_capacity = int(user_capacity) + self.user_rate = int(user_rate) + if min( + self.session_capacity, + self.session_rate, + self.user_capacity, + self.user_rate, + ) <= 0: + raise ValueError('SSH input budgets must be positive') + self._buckets = {} + self._lock = RLock() + + def _state(self, key, capacity, rate, now): + bucket = self._buckets.get(key) + if bucket is None: + bucket = _Bucket(float(capacity), now) + self._buckets[key] = bucket + return bucket + elapsed = max(0.0, now - bucket.updated_at) + bucket.tokens = min(float(capacity), bucket.tokens + elapsed * rate) + bucket.updated_at = now + return bucket + + def allow(self, user_id, session_id, byte_count, *, now=None): + """Return ``(allowed, retry_after_ms)`` without charging denials.""" + byte_count = int(byte_count) + if byte_count <= 0: + return True, 0 + current = time.monotonic() if now is None else float(now) + user_key = ('user', int(user_id)) + session_key = ('session', int(user_id), str(session_id)) + with self._lock: + user_bucket = self._state( + user_key, + self.user_capacity, + self.user_rate, + current, + ) + session_bucket = self._state( + session_key, + self.session_capacity, + self.session_rate, + current, + ) + deficits = ( + max(0.0, byte_count - user_bucket.tokens) + / self.user_rate, + max(0.0, byte_count - session_bucket.tokens) + / self.session_rate, + ) + wait_seconds = max(deficits) + if wait_seconds > 0: + return False, max(1, math.ceil(wait_seconds * 1000)) + user_bucket.tokens -= byte_count + session_bucket.tokens -= byte_count + if len(self._buckets) > 1024: + self._remove_stale_full_buckets(current) + return True, 0 + + def _remove_stale_full_buckets(self, now): + for key, bucket in tuple(self._buckets.items()): + if key[0] == 'user': + capacity = self.user_capacity + rate = self.user_rate + else: + capacity = self.session_capacity + rate = self.session_rate + refill_seconds = capacity / rate + if now - bucket.updated_at >= max(60.0, refill_seconds * 2): + self._buckets.pop(key, None) + + +def budget_from_config(config): + return SSHInputBudget( + session_capacity=config.SSH_INPUT_SESSION_BURST_BYTES, + session_rate=config.SSH_INPUT_SESSION_BYTES_PER_SECOND, + user_capacity=config.SSH_INPUT_USER_BURST_BYTES, + user_rate=config.SSH_INPUT_USER_BYTES_PER_SECOND, + ) diff --git a/app/ssh_manager.py b/app/ssh_manager.py index 45e93be..4f01b58 100644 --- a/app/ssh_manager.py +++ b/app/ssh_manager.py @@ -17,6 +17,7 @@ from .ssh_key_loader import load_private_key as _load_private_key from .ssh_errors import SSHConnectionError from .startup_commands import to_terminal_input +from .tailscale_ssh import TailscaleSSHAuthorization from . import paramiko_channels from .quota_manager import ( QuotaExceeded, @@ -144,7 +145,8 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke proxy_jump_password=None, proxy_jump_key_content=None, use_tmux=False, reconnect_tmux_name=None, auth_type='password', startup_commands='', - auth_banner_decision=None): + auth_banner_decision=None, + tailscale_authorization=None): """ Create a new SSH connection and return session ID. @@ -171,6 +173,22 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke return None, "User identity is required" user_id = host_key_store.user_id + tailscale_target_authorized = False + if auth_type == 'tailscale': + if ( + not isinstance( + tailscale_authorization, + TailscaleSSHAuthorization, + ) + or not tailscale_authorization.matches( + user_id, + host, + username, + ) + ): + return None, 'Tailscale SSH authorization is invalid' + tailscale_target_authorized = True + try: reservation = quota_manager.reserve( QuotaKind.SSH_SESSION, user_id @@ -193,7 +211,10 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke target = resolve_allowed_target( host, port, - allow_internal=not config.BLOCK_INTERNAL_SSH, + allow_internal=( + not config.BLOCK_INTERNAL_SSH + or tailscale_target_authorized + ), ) channel_destination = (target.ip, target.port) host = target.hostname @@ -287,7 +308,10 @@ def create_ssh_connection(host, port, username, password=None, key_path=None, ke target = resolve_allowed_target( host, port, - allow_internal=not config.BLOCK_INTERNAL_SSH, + allow_internal=( + not config.BLOCK_INTERNAL_SSH + or tailscale_target_authorized + ), ) host = target.hostname port = target.port diff --git a/app/tailscale_ssh.py b/app/tailscale_ssh.py index 32e2430..de87e44 100644 --- a/app/tailscale_ssh.py +++ b/app/tailscale_ssh.py @@ -1,7 +1,32 @@ """Authorization policy for the optional shared-identity Tailscale SSH mode.""" +from dataclasses import dataclass + import config +from .network_policy import canonicalize_hostname + + +@dataclass(frozen=True) +class TailscaleSSHAuthorization: + """Attempt-scoped authority for one exact shared-identity connection.""" + + user_id: int + host: str + remote_username: str + + def matches(self, user_id, host, remote_username): + try: + canonical_host = canonicalize_hostname(host) + clean_user_id = int(user_id) + except (TypeError, ValueError): + return False + return ( + clean_user_id == self.user_id + and canonical_host == self.host + and str(remote_username or '').strip() == self.remote_username + ) + def user_can_use_tailscale_ssh(user): """Return whether a WebSSH user may use the node's Tailscale identity.""" @@ -18,17 +43,46 @@ def validate_tailscale_ssh_access(user, host, remote_username): if not user_can_use_tailscale_ssh(user): return 'Tailscale SSH is not enabled for this account' - allowed_targets = config.TAILSCALE_SSH_ALLOWED_TARGETS - if allowed_targets and (host or '').strip().lower() not in allowed_targets: + try: + canonical_host = canonicalize_hostname(host) + except ValueError: return 'Tailscale SSH target is not allowed' + try: + allowed_targets = { + canonicalize_hostname(target) + for target in config.TAILSCALE_SSH_ALLOWED_TARGETS + } + except (TypeError, ValueError): + return 'Tailscale SSH target is not allowed' + if allowed_targets and canonical_host not in allowed_targets: + return 'Tailscale SSH target is not allowed' + + clean_remote_username = str(remote_username or '').strip() allowed_remote_users = config.TAILSCALE_SSH_ALLOWED_REMOTE_USERS - if allowed_remote_users and (remote_username or '').strip() not in allowed_remote_users: + if allowed_remote_users and clean_remote_username not in allowed_remote_users: return 'Tailscale SSH remote username is not allowed' return None +def authorize_tailscale_ssh_access(user, host, remote_username): + """Return an exact internal authorization object or a safe error.""" + error = validate_tailscale_ssh_access(user, host, remote_username) + if error: + return None, error + try: + user_id = int(getattr(user, 'id')) + canonical_host = canonicalize_hostname(host) + except (TypeError, ValueError): + return None, 'Tailscale SSH is not enabled for this account' + return TailscaleSSHAuthorization( + user_id=user_id, + host=canonical_host, + remote_username=str(remote_username or '').strip(), + ), None + + def profile_is_authorized_for_launch(user, profile): """Return whether a saved profile is still allowed by current policy.""" if not isinstance(profile, dict) or profile.get('auth_type') != 'tailscale': diff --git a/config.py b/config.py index 7424d6d..e03efa5 100644 --- a/config.py +++ b/config.py @@ -357,6 +357,65 @@ def _validate_quota_pair(kind, global_limit, per_user_limit, fair_slots): # 110 MiB socket-upload allowance. SOCKETIO_MAX_MESSAGE_SIZE = MAX_EDITOR_FILE_SIZE * 6 + 64 * 1024 +# Interactive terminal input has a substantially smaller resource budget than +# the bulk editor envelope. The upper bounds prevent deployment overrides from +# silently restoring editor-sized SSH input events. +SSH_INPUT_MAX_BYTES = _bounded_int_env( + 'SSH_INPUT_MAX_BYTES', 128 * 1024, 4 * 1024, 256 * 1024 +) +SSH_INPUT_SESSION_BURST_BYTES = _bounded_int_env( + 'SSH_INPUT_SESSION_BURST_BYTES', + 256 * 1024, + SSH_INPUT_MAX_BYTES, + 2 * 1024 * 1024, +) +SSH_INPUT_SESSION_BYTES_PER_SECOND = _bounded_int_env( + 'SSH_INPUT_SESSION_BYTES_PER_SECOND', + 256 * 1024, + 4 * 1024, + 2 * 1024 * 1024, +) +SSH_INPUT_USER_BURST_BYTES = _bounded_int_env( + 'SSH_INPUT_USER_BURST_BYTES', + 512 * 1024, + SSH_INPUT_SESSION_BURST_BYTES, + 4 * 1024 * 1024, +) +SSH_INPUT_USER_BYTES_PER_SECOND = _bounded_int_env( + 'SSH_INPUT_USER_BYTES_PER_SECOND', + 512 * 1024, + 4 * 1024, + 4 * 1024 * 1024, +) + +# Persistent command configuration shares DATA_DIR with the database, keys, +# trust stores, and logs. These hard upper bounds keep operator overrides from +# turning the command UI back into an unbounded shared-volume writer. +COMMAND_MAX_RECORDS = _bounded_int_env( + 'COMMAND_MAX_RECORDS', 500, 10, 2000 +) +COMMAND_SET_MAX_RECORDS = _bounded_int_env( + 'COMMAND_SET_MAX_RECORDS', 500, 10, 2000 +) +COMMAND_SET_MAX_STEPS = _bounded_int_env( + 'COMMAND_SET_MAX_STEPS', 64, 1, 256 +) +COMMAND_OS_MAX_ENTRIES = _bounded_int_env( + 'COMMAND_OS_MAX_ENTRIES', 16, 1, 64 +) +COMMAND_STORE_MAX_BYTES = _bounded_int_env( + 'COMMAND_STORE_MAX_BYTES', + 2 * 1024 * 1024, + 64 * 1024, + 8 * 1024 * 1024, +) +COMMAND_CONFIG_MAX_BYTES = _bounded_int_env( + 'COMMAND_CONFIG_MAX_BYTES', + 4 * 1024 * 1024, + COMMAND_STORE_MAX_BYTES, + 16 * 1024 * 1024, +) + # Admin panel: comma-separated usernames granted admin on startup. ADMIN_USERS = [u.strip() for u in os.environ.get('ADMIN_USERS', '').split(',') if u.strip()] ADMIN_PANEL_ENABLED = os.environ.get('ADMIN_PANEL_ENABLED', 'True') == 'True' @@ -489,6 +548,10 @@ def _validate_quota_pair(kind, global_limit, per_user_limit, fair_slots): # unthrottled SSH brute-force / port-scan proxy against third-party hosts. # Generous default so normal use and reconnects never hit it. RATELIMIT_SSH_CONNECT = os.environ.get('SSH_CONNECT_RATELIMIT', '10 per minute') +RATELIMIT_COMMAND_MUTATION = os.environ.get( + 'COMMAND_MUTATION_RATELIMIT', + '60 per minute', +) REGISTRATION_ENABLED = os.environ.get( 'REGISTRATION_ENABLED', diff --git a/static/js/app.js b/static/js/app.js index 173fd1e..d4dc6df 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1595,7 +1595,11 @@ navigator.clipboard.readText() .then(text => { if (window.socket && text) { - window.socket.emit('ssh_input', { session_id: active, data: text }); + if (window.SSHInput) { + window.SSHInput.send(active, text); + } else { + window.socket.emit('ssh_input', { session_id: active, data: text }); + } } }) .catch(() => showNotification('Clipboard access denied', 'error')); @@ -1634,7 +1638,12 @@ const active = SessionManager.getActiveSession(); if (!active || !mobileInput.value) return; if (window.socket) { - window.socket.emit('ssh_input', { session_id: active, data: mobileInput.value + '\r' }); + const data = mobileInput.value + '\r'; + if (window.SSHInput) { + window.SSHInput.send(active, data); + } else { + window.socket.emit('ssh_input', { session_id: active, data }); + } } mobileInput.value = ''; }; diff --git a/static/js/broadcast-input.js b/static/js/broadcast-input.js index 848d579..18ccf56 100644 --- a/static/js/broadcast-input.js +++ b/static/js/broadcast-input.js @@ -58,7 +58,12 @@ if (!window.socket) return 0; const sessions = connectedSessions(); sessions.forEach(s => { - window.socket.emit('ssh_input', { session_id: s.id, data: text + '\r' }); + const data = text + '\r'; + if (window.SSHInput) { + window.SSHInput.send(s.id, data); + } else { + window.socket.emit('ssh_input', { session_id: s.id, data }); + } }); return sessions.length; } diff --git a/static/js/command-library.js b/static/js/command-library.js index 1f5a49e..93f0382 100644 --- a/static/js/command-library.js +++ b/static/js/command-library.js @@ -292,10 +292,14 @@ const CommandLibrary = { } if (window.socket) { - window.socket.emit('ssh_input', { - session_id: activeSessionId, - data: fullCommand - }); + if (window.SSHInput) { + window.SSHInput.send(activeSessionId, fullCommand); + } else { + window.socket.emit('ssh_input', { + session_id: activeSessionId, + data: fullCommand + }); + } } this.closeLibrary(); diff --git a/static/js/session-command-launcher.js b/static/js/session-command-launcher.js index 14deb0d..ac79751 100644 --- a/static/js/session-command-launcher.js +++ b/static/js/session-command-launcher.js @@ -177,10 +177,12 @@ getSession: sessionId => getSessionManager()?.getSession(sessionId), getCommands: () => getCommandLibrary()?.commands || [], getCommandSets: () => root.CommandSetManager?.commandSets || [], - emitInput: (sessionId, data) => root.socket?.emit('ssh_input', { - session_id: sessionId, - data, - }), + emitInput: (sessionId, data) => root.SSHInput + ? root.SSHInput.send(sessionId, data) + : root.socket?.emit('ssh_input', { + session_id: sessionId, + data, + }), focusSession: sessionId => getTerminalManager()?.terminals?.[sessionId]?.focus(), notify: (message, type) => root.showNotification?.(message, type), insertedMessage: label => this.t( diff --git a/static/js/session-manager.js b/static/js/session-manager.js index e2768fe..e22675b 100644 --- a/static/js/session-manager.js +++ b/static/js/session-manager.js @@ -141,10 +141,14 @@ const SessionManager = { // Bare-pattern regexes were removed because they corrupt legitimate input. data = data.replace(/\x1b\[[?>]?[0-9;]*c/g, ''); if (data) { - window.socket.emit('ssh_input', { - session_id: session_id, - data: data - }); + if (window.SSHInput) { + window.SSHInput.send(session_id, data); + } else { + window.socket.emit('ssh_input', { + session_id: session_id, + data: data + }); + } } } }); diff --git a/static/js/ssh-input.js b/static/js/ssh-input.js new file mode 100644 index 0000000..5af83ca --- /dev/null +++ b/static/js/ssh-input.js @@ -0,0 +1,115 @@ +/* Ordered, UTF-8-safe transport for interactive SSH input and large paste. */ +(function (root) { + 'use strict'; + + const SAFE_FALLBACK_CHUNK_BYTES = 4 * 1024; + const configuredMaxBytes = Number( + root.WEBSSH_SSH_INPUT_LIMITS?.maxEventBytes, + ); + const CHUNK_BYTES = Number.isSafeInteger(configuredMaxBytes) + && configuredMaxBytes > 0 + ? Math.min(64 * 1024, configuredMaxBytes) + : SAFE_FALLBACK_CHUNK_BYTES; + const ACK_TIMEOUT_MS = 10000; + const MAX_BACKPRESSURE_RETRIES = 240; + const encoder = new TextEncoder(); + const decoder = new TextDecoder('utf-8', {fatal: true}); + const sessionQueues = new Map(); + + function byteChunks(value, maxBytes = CHUNK_BYTES) { + const bytes = encoder.encode(String(value)); + if (bytes.length <= maxBytes) return [String(value)]; + const chunks = []; + let offset = 0; + while (offset < bytes.length) { + let end = Math.min(offset + maxBytes, bytes.length); + while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end -= 1; + if (end <= offset) throw new Error('Unable to split SSH input safely'); + chunks.push(decoder.decode(bytes.subarray(offset, end))); + offset = end; + } + return chunks; + } + + function emitWithAck(socket, payload) { + return new Promise((resolve, reject) => { + const timeout = root.setTimeout( + () => reject(new Error('SSH input acknowledgement timed out')), + ACK_TIMEOUT_MS, + ); + socket.emit('ssh_input', payload, acknowledgement => { + root.clearTimeout(timeout); + resolve(acknowledgement || {success: true}); + }); + }); + } + + function notifyFailure(message) { + root.showNotification?.(message || 'SSH input could not be sent', 'error'); + } + + async function transmit(sessionId, chunks) { + if (chunks.length === 1) { + root.socket.emit('ssh_input', {session_id: sessionId, data: chunks[0]}); + return true; + } + + try { + for (const chunk of chunks) { + let retries = 0; + while (true) { + const result = await emitWithAck(root.socket, { + session_id: sessionId, + data: chunk, + acknowledge_backpressure: true, + }); + if (result.success !== false) break; + if ( + result.code !== 'ssh_input_backpressure' + || retries >= MAX_BACKPRESSURE_RETRIES + ) { + throw new Error(result.error || 'SSH input was rejected'); + } + retries += 1; + const delay = Math.min( + 5000, + Math.max(1, Number(result.retry_after_ms) || 1), + ); + await new Promise(resolve => root.setTimeout(resolve, delay)); + } + } + return true; + } catch (error) { + notifyFailure(error.message); + return false; + } + } + + function send(sessionId, value) { + if (!root.socket || !sessionId || typeof value !== 'string' || !value) { + return Promise.resolve(false); + } + const chunks = byteChunks(value); + const pending = sessionQueues.get(sessionId); + if (!pending && chunks.length === 1) { + root.socket.emit('ssh_input', { + session_id: sessionId, + data: chunks[0], + }); + return Promise.resolve(true); + } + + const queued = pending + ? pending.catch(() => false).then(() => transmit(sessionId, chunks)) + : transmit(sessionId, chunks); + sessionQueues.set(sessionId, queued); + queued.finally(() => { + if (sessionQueues.get(sessionId) === queued) { + sessionQueues.delete(sessionId); + } + }); + return queued; + } + + root.SSHInput = Object.freeze({byteChunks, send, CHUNK_BYTES}); +}(window)); diff --git a/templates/index.html b/templates/index.html index aa7e1e2..5a6d648 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1386,6 +1386,7 @@

File Preview

@@ -1406,6 +1407,7 @@

File Preview

+ @@ -1414,7 +1416,7 @@

File Preview

- + @@ -1424,7 +1426,7 @@

File Preview

- +