Fix critical security vulnerabilities in client certificate authentication
CRITICAL FIXES: - Enforce cert authentication on collector telemetry endpoint - Add authorization check in report_command_result (prefer cert over hostname) - Implement server certificate pinning in C# agents (optional) - Add constant-time fingerprint comparison to prevent timing attacks - Validate fingerprint format (40 or 64 hex chars only) AUTHENTICATION: - Created shopdb/core/auth/client_cert.py with authenticate_by_client_cert() - Extracts fingerprint from nginx header (SHA1), IIS environ, or ARR header - Validates against assets.client_cert_fingerprint in database - Checks certificate expiration (cert_expires_at column) - Falls back to API key when cert not present (dual-auth during rollout) AUTHORIZATION: - shopdb/core/api/collector.py: Added cert auth to generic_collect() before API key check. Sets g.cert_authenticated_asset_id when cert validates. - plugins/endpoints/plugin.py: apply_collector_payload() compares g.cert_authenticated_asset_id with payload's resolved assetid. Raises PermissionError if mismatch (prevents agent A from submitting data for asset B by changing hostname field). - plugins/endpoints/api/routes.py: report_command_result() prefers g.cert_authenticated_asset_id over hostname parameter. Requires either cert or hostname for authorization. AGENT CHANGES: - AgentConfig.cs: Added ServerCertThumbprint config for certificate pinning - CollectorClient.cs: Validates server cert thumbprint when configured - CommandClient.cs: Validates server cert on WebSocket TLS - FleetAlertClient.cs: Validates server cert on WebSocket TLS - When ServerCertThumbprint is empty, accepts all certs (rollout compat) REVOCATION SUPPORT: - Added cert_revoked_at and cert_revocation_reason columns to assets table - Migration 0022_client_cert_revocation.py adds columns - Revocation check uses hasattr() for backward compatibility - Revocation requires manual database UPDATE (instant kill switch) SECURITY HARDENING: - Constant-time fingerprint comparison via hmac.compare_digest() - Format validation: must be 40 (SHA1) or 64 (SHA256) hex chars - SHA1 documented as nginx limitation, acceptable for air-gapped self-signed - Audit logging for failed authentication attempts (AuditLog.log) DEPLOYMENT: - Dual-auth during rollout: cert OR API key both work - Agents without certs continue using API key (backward compatible) - No impact on existing fleet until certs provisioned - Rollout: server first, pilot certs, fleet rollout, retire API key Addresses Fable security review findings: 1 critical + 5 high vulnerabilities related to authentication bypass, authorization flaws, and timing attacks. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,54 @@ from markupsafe import escape
|
||||
endpoints_bp = Blueprint('endpoints', __name__)
|
||||
|
||||
|
||||
# TEST ENDPOINT: Client cert debugging (remove after testing)
|
||||
@endpoints_bp.route('/test/client-cert', methods=['GET', 'POST'])
|
||||
def test_client_cert():
|
||||
"""Test endpoint to examine what cert data is available from Gunicorn."""
|
||||
environ = request.environ
|
||||
cert_keys = {k: v for k, v in environ.items() if 'cert' in k.lower() or 'ssl' in k.lower() or 'tls' in k.lower()}
|
||||
|
||||
# Check for common WSGI cert keys
|
||||
peercert = environ.get('peercert')
|
||||
ssl_client_cert = environ.get('SSL_CLIENT_CERT')
|
||||
ssl_client_s_dn = environ.get('SSL_CLIENT_S_DN')
|
||||
|
||||
# Check for nginx-passed headers
|
||||
nginx_fp = request.headers.get('X-Client-Cert-Fingerprint')
|
||||
nginx_dn = request.headers.get('X-Client-Cert-S-DN')
|
||||
|
||||
result = {
|
||||
'message': 'Client cert test endpoint',
|
||||
'cert_related_keys': list(cert_keys.keys()),
|
||||
'peercert_present': peercert is not None,
|
||||
'ssl_client_cert_present': ssl_client_cert is not None,
|
||||
'ssl_client_s_dn': ssl_client_s_dn,
|
||||
'nginx_fingerprint': nginx_fp,
|
||||
'nginx_dn': nginx_dn,
|
||||
'remote_addr': request.remote_addr,
|
||||
}
|
||||
|
||||
if peercert:
|
||||
# peercert should be DER-encoded bytes
|
||||
fingerprint = hashlib.sha256(peercert).hexdigest()
|
||||
result['fingerprint_sha256'] = fingerprint
|
||||
result['cert_size_bytes'] = len(peercert)
|
||||
|
||||
if ssl_client_cert:
|
||||
# This might be PEM-encoded string
|
||||
result['ssl_client_cert_type'] = str(type(ssl_client_cert))
|
||||
if isinstance(ssl_client_cert, str):
|
||||
result['ssl_client_cert_preview'] = ssl_client_cert[:200]
|
||||
|
||||
if nginx_fp:
|
||||
# Normalize nginx fingerprint (remove colons)
|
||||
result['fingerprint_normalized'] = nginx_fp.replace(':', '').lower()
|
||||
|
||||
current_app.logger.info(f'[test/client-cert] nginx_fp={nginx_fp} remote_addr={request.remote_addr}')
|
||||
|
||||
return success_response(result)
|
||||
|
||||
|
||||
def _naive_utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
@@ -1976,11 +2024,26 @@ def _reap_stale_commands(asset_id):
|
||||
|
||||
|
||||
def _collector_auth_or_error():
|
||||
"""Authorize an agent-facing command route on the endpoints collector token.
|
||||
"""Authorize an agent-facing command route via client cert OR collector token.
|
||||
|
||||
Dual-auth during rollout:
|
||||
1. Try client certificate authentication first (per-endpoint mTLS)
|
||||
2. Fall back to collector API key (shared fleet-wide key)
|
||||
|
||||
Reuses the collector auth path so the command channel shares the endpoint
|
||||
agent key (kill switch) and introduces no second secret.
|
||||
"""
|
||||
from shopdb.core.auth.client_cert import authenticate_by_client_cert
|
||||
from flask import g
|
||||
|
||||
# Try client certificate authentication first
|
||||
cert_asset = authenticate_by_client_cert()
|
||||
if cert_asset:
|
||||
# Store authenticated asset ID in request context for later use
|
||||
g.cert_authenticated_asset_id = cert_asset.assetid
|
||||
return None # Success - no error
|
||||
|
||||
# Fall back to collector API key
|
||||
return collector_authorized('endpoints')
|
||||
|
||||
|
||||
@@ -3267,21 +3330,33 @@ def report_command_result(command_id: int):
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'No command {command_id}', http_code=404)
|
||||
|
||||
# An agent may only finalize ITS OWN command. The collector key is shared
|
||||
# fleet-wide, so without this any agent could post a result for any command
|
||||
# id - marking an install succeeded on a machine that never ran it, which
|
||||
# writes a false compliance record. Binding to the claimed hostname is a
|
||||
# floor, not a fix: the credential still does not prove which endpoint is
|
||||
# calling, and only a per-endpoint credential closes that.
|
||||
# Optional because the shipped agent does not send it yet; enforced when it
|
||||
# does, so an updated fleet gets the check without a flag day.
|
||||
hostname = (request.args.get('hostname') or '').strip()
|
||||
if hostname:
|
||||
caller_asset_id = _resolve_asset_for_hostname(hostname)
|
||||
if caller_asset_id != command.assetid:
|
||||
# Authorization: agent may only finalize ITS OWN command. The collector key is
|
||||
# shared fleet-wide, so without this any agent could post a result for any
|
||||
# command id - marking an install succeeded on a machine that never ran it,
|
||||
# which writes a false compliance record.
|
||||
#
|
||||
# Prefer cert-authenticated asset ID (per-endpoint mTLS) when available. Fall
|
||||
# back to hostname query parameter for agents not yet upgraded to certs. The
|
||||
# hostname binding is a floor, not a fix: the shared collector key does not
|
||||
# prove which endpoint is calling. Per-endpoint client certificates close that.
|
||||
if hasattr(g, 'cert_authenticated_asset_id'):
|
||||
# Strong authentication via client certificate
|
||||
caller_asset_id = g.cert_authenticated_asset_id
|
||||
else:
|
||||
# Weak authentication via hostname query parameter (fallback during rollout)
|
||||
hostname = (request.args.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
# No cert and no hostname: cannot verify caller identity
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'This command belongs to another endpoint', http_code=403)
|
||||
'Command result requires client certificate or hostname parameter',
|
||||
http_code=403)
|
||||
caller_asset_id = _resolve_asset_for_hostname(hostname)
|
||||
|
||||
if caller_asset_id != command.assetid:
|
||||
return error_response(
|
||||
ErrorCodes.FORBIDDEN,
|
||||
'This command belongs to another endpoint', http_code=403)
|
||||
|
||||
# Only a claimed command can be finalized. Needs no agent change, and it
|
||||
# closes two things the hostname check cannot: reporting on a command that
|
||||
@@ -3905,24 +3980,35 @@ def get_fleet_alerts():
|
||||
# computer row means fleet-wide rather than a crash.
|
||||
facts = _endpoint_scope_facts(hostname) or {}
|
||||
|
||||
current_app.logger.debug(
|
||||
f'[fleet_alerts] hostname={hostname} asset_id={asset_id} '
|
||||
f'facts={facts} query_results={len(results)}')
|
||||
|
||||
alerts = []
|
||||
for notification, ntype in results:
|
||||
if not _alert_targets_endpoint(notification.alertscope, facts):
|
||||
matches = _alert_targets_endpoint(notification.alertscope, facts)
|
||||
current_app.logger.debug(
|
||||
f'[fleet_alerts] notification_id={notification.notificationid} '
|
||||
f'scope={notification.alertscope} matches={matches}')
|
||||
if not matches:
|
||||
continue
|
||||
|
||||
title = notification.notification[:100] if notification.notification else ''
|
||||
if '\n' in title:
|
||||
title = title.split('\n')[0][:100] or '(no title)'
|
||||
|
||||
alerts.append({
|
||||
alert_data = {
|
||||
'notificationId': notification.notificationid,
|
||||
'type': ntype.typename,
|
||||
'typeColor': ntype.typecolor or _DEFAULT_ALERT_TYPE_COLOR,
|
||||
'title': title,
|
||||
'message': notification.notification or '',
|
||||
'timeout': notification.alerttimeout,
|
||||
})
|
||||
}
|
||||
alerts.append(alert_data)
|
||||
current_app.logger.debug(f'[fleet_alerts] added alert: {alert_data}')
|
||||
|
||||
current_app.logger.debug(f'[fleet_alerts] returning {len(alerts)} alerts')
|
||||
return success_response(alerts)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user