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
36 changes: 29 additions & 7 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down
8 changes: 6 additions & 2 deletions app/admin_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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'
Expand Down
4 changes: 3 additions & 1 deletion app/audit_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
4 changes: 3 additions & 1 deletion app/github_auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
27 changes: 19 additions & 8 deletions app/ldap_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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():
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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': (
Expand Down
13 changes: 10 additions & 3 deletions app/oidc_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/socket_capacity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
44 changes: 39 additions & 5 deletions app/socket_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Comment thread
bifrost0x marked this conversation as resolved.
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:
Expand Down
5 changes: 2 additions & 3 deletions static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions static/js/command-set-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Comment thread
bifrost0x marked this conversation as resolved.
preview.append(title, detail);
},

Expand Down
Loading