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
39 changes: 39 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,9 @@ def enforce_security_feature_gate():
from .audit_export import audit_export_blueprint
from .admin_backup import admin_backup_blueprint
from .account_step_up_routes import account_step_up_blueprint
from .account_sessions import account_sessions_blueprint
from .health import health_blueprint
from .support_report import support_report_blueprint
from .host_key_routes import host_key_blueprint
from .profile_exchange_routes import profile_exchange_blueprint
from .host_context_routes import host_context_blueprint
Expand All @@ -835,8 +837,10 @@ def enforce_security_feature_gate():
oidc_ready = True
app.register_blueprint(audit_export_blueprint)
app.register_blueprint(account_step_up_blueprint)
app.register_blueprint(account_sessions_blueprint)
app.register_blueprint(admin_backup_blueprint)
app.register_blueprint(health_blueprint)
app.register_blueprint(support_report_blueprint)
from .operations import operations_blueprint
app.register_blueprint(operations_blueprint)
app.register_blueprint(host_key_blueprint)
Expand Down Expand Up @@ -1827,6 +1831,23 @@ def admin_get_security_features():
],
})

@app.get('/admin/api/security-features/<feature_name>/impact')
@admin_required
@login_required
def admin_security_feature_impact(feature_name):
from .auth_assurance import current_authentication_session
from .security_feature_impact import feature_impact_preview
from .security_features import UnknownSecurityFeature
auth = current_authentication_session()
if auth is None:
abort(401)
try:
response = jsonify(feature_impact_preview(feature_name, current_user.id, auth.id))
except UnknownSecurityFeature:
abort(404)
response.headers['Cache-Control'] = 'no-store'
return response

@app.route(
'/admin/api/security-features/<feature_name>',
methods=['POST'],
Expand All @@ -1849,12 +1870,17 @@ def admin_set_security_feature(feature_name):
if not isinstance(data, dict) or type(data.get('enabled')) is not bool:
return jsonify({'error': 'enabled must be a boolean'}), 400
try:
# Keep approval verification and gate mutation in one SQLite write
# transaction: another account/factor edit must not slip between.
from sqlalchemy import text
db.session.execute(text('BEGIN IMMEDIATE'))
current_status = feature_status(feature_name)
if (
current_status.active
and data['enabled'] is False
and data.get('confirm_session_fallback') is not True
):
db.session.rollback()
log_warning(
'Security feature change needs fallback confirmation',
admin=current_user.username,
Expand All @@ -1870,6 +1896,18 @@ def admin_set_security_feature(feature_name):
),
'feature': current_status.to_dict(),
}), 409
if current_status.active and data['enabled'] is False:
from .auth_assurance import current_authentication_session
from .security_feature_impact import verify_impact_approval
auth = current_authentication_session()
if auth is None or not verify_impact_approval(
data.get('impact_approval'), feature_name, current_user.id, auth.id,
):
db.session.rollback()
return jsonify({
'error': 'Account configuration changed or approval expired. Review the impact again.',
'code': 'feature_impact_changed',
}), 409
status = set_feature_active(
feature_name,
data['enabled'],
Expand All @@ -1878,6 +1916,7 @@ def admin_set_security_feature(feature_name):
except UnknownSecurityFeature:
abort(404)
except FeatureUnavailable as exc:
db.session.rollback()
return jsonify({
'error': exc.status.reason,
'feature': exc.status.to_dict(),
Expand Down
148 changes: 148 additions & 0 deletions app/account_sessions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Owned browser-login inventory and selective access revocation."""
from datetime import datetime, timezone
import hashlib
import hmac
import re

from flask import Blueprint, current_app, jsonify, request
from flask_login import current_user, login_required

from .audit_logger import log_security_event, log_warning
from .auth_assurance import authentication_methods, current_authentication_session
from .models import AuthenticationSession, GitHubOAuthState, OIDCLoginState, SocketSession, StepUpIntent, User, db
from .login_access import serialize_login_access

account_sessions_blueprint = Blueprint('account_sessions', __name__)


def login_cancellation_check(auth):
"""Capture an exact login, then recheck it during an ongoing HTTP command."""
auth_id, user_id, digest, generation = auth.id, auth.user_id, auth.session_hash, auth.auth_generation
lifecycle = current_app.extensions['runtime_lifecycle']

def cancelled():
if not lifecycle.accepting_work():
return True
return db.session.query(AuthenticationSession.id).join(User).filter(
AuthenticationSession.id == auth_id,
AuthenticationSession.user_id == user_id,
AuthenticationSession.session_hash == digest,
AuthenticationSession.auth_generation == generation,
AuthenticationSession.expires_at > datetime.now(timezone.utc).replace(tzinfo=None),
User.auth_generation == generation,
User.is_locked.is_(False),
).first() is None

return cancelled


def _handle(row):
# A management-only reference, domain-separated from all login credentials.
key = str(current_app.secret_key).encode('utf-8')
return hmac.new(key, f'login-inventory:{row.id}:{row.session_hash}'.encode(), hashlib.sha256).hexdigest()


def _live_sessions():
return AuthenticationSession.query.filter(
AuthenticationSession.user_id == current_user.id,
AuthenticationSession.auth_generation == int(current_user.auth_generation or 0),
AuthenticationSession.expires_at > datetime.now(timezone.utc).replace(tzinfo=None),
).order_by(AuthenticationSession.authenticated_at.desc(), AuthenticationSession.id.desc()).all()


def _response(**payload):
response = jsonify(**payload)
response.headers['Cache-Control'] = 'no-store'
return response


@account_sessions_blueprint.get('/api/account/sessions')
@login_required
def list_sessions():
current = current_authentication_session()
if current is None:
return _response(error='Authentication required'), 401
return _response(sessions=[{
'handle': _handle(row),
'current': row.id == current.id,
'methods': authentication_methods(row),
'started_at': row.authenticated_at.isoformat() + 'Z',
'expires_at': row.expires_at.isoformat() + 'Z',
} for row in _live_sessions()])


@account_sessions_blueprint.post('/api/account/sessions/revoke')
@login_required
@serialize_login_access
def revoke_sessions():
db.session.expire_all()
current = current_authentication_session()
if current is None:
return _response(error='Authentication required'), 401
body = request.get_json(silent=True)
if not isinstance(body, dict) or not (
set(body) == {'others'} and body['others'] is True
or set(body) == {'session'} and isinstance(body['session'], str) and re.fullmatch(r'[0-9a-f]{64}', body['session'])
):
return _response(error='Select a login or all other logins'), 400
rows = _live_sessions()
if body.get('others') is True:
selected = [row for row in rows if row.id != current.id]
else:
selected = [row for row in rows if hmac.compare_digest(_handle(row), body['session'])]
if not selected:
return _response(error='Login is no longer available'), 404
if selected[0].id == current.id:
return _response(error='Use Sign out to end this login'), 409
selected_ids = [row.id for row in selected]
selected_identities = [(row.user_id, row.id, row.session_hash) for row in selected]
sockets = SocketSession.query.filter(
SocketSession.user_id == current_user.id,
SocketSession.authentication_session_id.in_(selected_ids),
).all() if selected_ids else []
socket_sids = [row.socket_sid for row in sockets]
intents = [row.id for row in StepUpIntent.query.filter(
StepUpIntent.authentication_session_id.in_(selected_ids),
)] if selected_ids else []
if selected_ids:
for model in (GitHubOAuthState, OIDCLoginState):
model.query.filter(db.or_(
model.authentication_session_id.in_(selected_ids),
model.step_up_intent_id.in_(intents),
)).delete(synchronize_session=False)
for row in selected:
db.session.delete(row)
db.session.commit()

# Commit the authentication fence before interrupting jobs or transports.
# Retained account-owned SSH/tmux sessions are deliberately not destroyed.
cleanup_errors = False
from .discovery_routes import cancel_for_authentication_sessions
cancel_for_authentication_sessions(selected_identities)
for session_id in selected_ids:
for name in ('credentials', 'automation'):
service = current_app.extensions.get(name)
if service is not None:
try:
service.revoke_session(session_id)
except Exception as error:
cleanup_errors = True
log_warning('Login resource cleanup failed', resource=name, error_type=type(error).__name__)
from . import socketio
from .socket_events import disconnect_socket_transport
from .transfer_routes import transfer_manager
for sid in socket_sids:
# Also cancel directly so a failing transport teardown cannot preserve
# an already-consumed HTTP transfer token or its streaming response.
try:
transfer_manager.cancel_all_for_socket(current_user.id, sid)
except Exception as error:
cleanup_errors = True
log_warning('Login transfer cleanup failed', error_type=type(error).__name__)
try:
disconnect_socket_transport(socketio.server, sid, drain=False)
except Exception as error:
cleanup_errors = True
log_warning('Login socket cleanup failed', error_type=type(error).__name__)
log_security_event('BROWSER_LOGINS_REVOKED', user=current_user.username, count=len(selected_ids))
return _response(revoked=len(selected_ids), cleanup_incomplete=cleanup_errors)
6 changes: 5 additions & 1 deletion app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,12 +338,16 @@ def register_socket_session(user_id, socket_sid, user_agent=None):
Returns:
SocketSession object
"""
from flask import has_request_context
from .auth_assurance import current_authentication_session
auth = current_authentication_session() if has_request_context() else None
SocketSession.query.filter_by(socket_sid=socket_sid).delete()

socket_session = SocketSession(
user_id=user_id,
socket_sid=socket_sid,
user_agent=user_agent
user_agent=user_agent,
authentication_session_id=auth.id if auth and auth.user_id == user_id else None,
)
db.session.add(socket_session)
db.session.commit()
Expand Down
2 changes: 2 additions & 0 deletions app/auth_assurance.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
)
from .session_epoch import current_epoch
from .totp_service import disable_totp_mfa
from .login_access import serialize_login_access


_PENDING_TTL = timedelta(minutes=5)
Expand Down Expand Up @@ -406,6 +407,7 @@ def invalidate_user_authentication(
return row


@serialize_login_access
def finalize_login(pending, *, methods, strong_authenticated_at=None):
"""Create the sole authenticated browser-session record."""
if not isinstance(pending, PendingAuthentication):
Expand Down
48 changes: 37 additions & 11 deletions app/discovery_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
import config
from .audit_logger import log_security_event
from .auth import check_user_rate_limit
from .login_access import login_access
from .auth_assurance import current_authentication_session

discovery_blueprint = Blueprint('discovery', __name__)

Expand Down Expand Up @@ -159,9 +161,11 @@ def _known_endpoints(user_id):


class Scan:
def __init__(self, user_id, network, addresses, ports, known):
def __init__(self, user_id, network, addresses, ports, known, authentication_session_id=None, authentication_session_hash=None):
self.id = secrets.token_urlsafe(12)
self.user_id = user_id
self.authentication_session_id = authentication_session_id
self.authentication_session_hash = authentication_session_hash
self.network = network
self.targets = [(address, port) for address in addresses for port in ports]
self.known = known
Expand Down Expand Up @@ -240,6 +244,9 @@ def _own_scan(scan_id):
@discovery_blueprint.post('/api/discovery/scans')
@login_required
def start_scan():
auth = current_authentication_session()
if auth is None:
return jsonify(error='Authentication required'), 401
if not discovery_available():
return jsonify(error='Host discovery is not available on this instance.', code='unavailable'), 404
lifecycle = current_app.extensions['runtime_lifecycle']
Expand All @@ -258,16 +265,20 @@ def start_scan():
known = _known_endpoints(user_id)
if check_user_rate_limit(user_id, 'host_discovery', RATE_LIMIT):
return jsonify(error='Too many scans. Try again later.', code='busy'), 429
scan = Scan(user_id, network, addresses, ports, known)
# Admission and registration happen under one lock, so concurrent starts cannot pass the limits together.
with _LOCK:
_prune()
# A cancelled scan counts until its in-flight probes have ended.
if any(item.user_id == user_id and item.finished_at is None for item in _SCANS.values()):
return jsonify(error='A scan is already running. Wait for it or cancel it.', code='running'), 409
if sum(item.finished_at is None for item in _SCANS.values()) >= MAX_RUNNING_SCANS:
return jsonify(error='Another discovery is running. Try again shortly.', code='busy'), 429
_SCANS[scan.id] = scan
scan = Scan(user_id, network, addresses, ports, known, auth.id, auth.session_hash)
# Only final admission is serialized with revocation. Validation and scan
# construction may proceed concurrently, without delaying login cleanup.
with login_access():
if current_authentication_session() is None:
return jsonify(error='Authentication required'), 401
with _LOCK:
_prune()
# A cancelled scan counts until its in-flight probes have ended.
if any(item.user_id == user_id and item.finished_at is None for item in _SCANS.values()):
return jsonify(error='A scan is already running. Wait for it or cancel it.', code='running'), 409
if sum(item.finished_at is None for item in _SCANS.values()) >= MAX_RUNNING_SCANS:
return jsonify(error='Another discovery is running. Try again shortly.', code='busy'), 429
_SCANS[scan.id] = scan
_RUNNERS.submit(scan.run)
log_security_event('host_discovery_started', user_id=user_id, network=network, addresses=len(addresses), ports=ports)
return jsonify(scan.view()), 202
Expand Down Expand Up @@ -311,6 +322,21 @@ def cancel_for_user(user_id):
return len(owned)


def cancel_for_authentication_sessions(identities):
"""Cancel only discovery launched by the revoked browser logins."""
with _LOCK:
owned = [scan for scan in _SCANS.values() if (
scan.user_id, scan.authentication_session_id, scan.authentication_session_hash,
) in identities]
for scan in owned:
scan.cancel_event.set()
scan.revoked = True
scan.found = []
if scan.state == 'running':
scan.state = 'cancelled'
return len(owned)


def cancel_all(_deadline=None):
"""Shutdown hook: stop every running scan; in-flight probes end within their timeouts."""
with _LOCK:
Expand Down
Loading
Loading