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:
Proudlock
2026-09-14 13:58:02 -04:00
parent d5d2cea3e4
commit 18e5fdc000
11 changed files with 913 additions and 47 deletions

View File

@@ -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)