Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
43 changes: 40 additions & 3 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
108 changes: 108 additions & 0 deletions app/auth_assurance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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):
Expand Down
7 changes: 7 additions & 0 deletions app/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,13 +277,20 @@ 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:
with operation_lock():
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
Expand Down
59 changes: 49 additions & 10 deletions app/command_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand All @@ -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',
Expand Down Expand Up @@ -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, []
Loading
Loading