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

View File

@@ -0,0 +1,211 @@
"""Client certificate authentication helpers.
Extracts client certificate fingerprints from various deployment types:
- IIS 2019: SSL_CLIENT_CERT environ (PEM format)
- Nginx: X-Client-Cert-Fingerprint header (SHA1 hex, colon-separated)
- IIS with ARR: X-ARR-ClientCert header (base64 DER)
SECURITY: Nginx $ssl_client_fingerprint outputs SHA1 (40 hex chars), not SHA256.
This is a known Nginx limitation. SHA1 is deprecated for signing but remains
acceptable for fingerprinting self-signed certificates in air-gapped environments
with short-lived certs and revocation support.
"""
import hashlib
import base64
import hmac
import re
from flask import request, current_app
def get_client_cert_fingerprint():
"""Extract client certificate fingerprint from request.
Returns:
str: 40-character lowercase hex SHA1 fingerprint, or None if no cert
Supports multiple deployment types:
- Nginx: reads X-Client-Cert-Fingerprint header (SHA1, colon-separated)
- IIS direct: reads SSL_CLIENT_CERT environ (PEM format, computes SHA256)
- IIS with ARR: reads X-ARR-ClientCert header (base64 DER, computes SHA256)
SECURITY: Nginx returns SHA1 (40 hex chars). IIS paths compute SHA256 (64 hex
chars). The database client_cert_fingerprint column holds 64 chars to support
future SHA256 migration but currently stores SHA1 from Nginx deployments.
"""
# Option 1: Nginx passes pre-computed SHA1 fingerprint
# Format: "AB:CD:EF:..." (colon-separated uppercase hex)
nginx_fp = request.headers.get('X-Client-Cert-Fingerprint')
if nginx_fp:
# Normalize: remove colons, lowercase
normalized = nginx_fp.replace(':', '').lower()
# Validate format: must be 40 hex chars (SHA1) or 64 hex chars (SHA256)
if len(normalized) == 40:
# SHA1 from nginx $ssl_client_fingerprint
if not re.match(r'^[0-9a-f]{40}$', normalized):
current_app.logger.warning(
f'[client_cert] Invalid SHA1 fingerprint format: {normalized[:16]}...')
return None
return normalized
elif len(normalized) == 64:
# SHA256 from custom nginx config or future migration
if not re.match(r'^[0-9a-f]{64}$', normalized):
current_app.logger.warning(
f'[client_cert] Invalid SHA256 fingerprint format: {normalized[:16]}...')
return None
return normalized
else:
current_app.logger.warning(
f'[client_cert] Invalid fingerprint length: {len(normalized)} '
f'(expected 40 for SHA1 or 64 for SHA256)')
return None
# Option 2: IIS passes full cert in PEM format via WSGI environ
iis_cert_pem = request.environ.get('SSL_CLIENT_CERT')
if iis_cert_pem:
try:
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
# Parse PEM certificate
if isinstance(iis_cert_pem, str):
iis_cert_pem = iis_cert_pem.encode('utf-8')
cert = x509.load_pem_x509_certificate(iis_cert_pem, default_backend())
# Convert to DER and hash
cert_der = cert.public_bytes(serialization.Encoding.DER)
fingerprint = hashlib.sha256(cert_der).hexdigest()
current_app.logger.debug(
f'[client_cert] Extracted from IIS SSL_CLIENT_CERT: {fingerprint[:16]}...')
return fingerprint
except Exception as e:
current_app.logger.warning(f'[client_cert] Failed to parse IIS cert: {e}')
return None
# Option 3: IIS with ARR passes base64-encoded DER
arr_cert_b64 = request.headers.get('X-ARR-ClientCert')
if arr_cert_b64:
try:
# Decode base64 to get DER bytes
cert_der = base64.b64decode(arr_cert_b64)
fingerprint = hashlib.sha256(cert_der).hexdigest()
current_app.logger.debug(
f'[client_cert] Extracted from ARR header: {fingerprint[:16]}...')
return fingerprint
except Exception as e:
current_app.logger.warning(f'[client_cert] Failed to parse ARR cert: {e}')
return None
# No client certificate present
return None
def authenticate_by_client_cert():
"""Authenticate request by client certificate.
Returns:
Asset: The authenticated asset, or None if cert auth fails
Checks:
1. Client cert present in request?
2. Fingerprint matches an asset in database?
3. Certificate not revoked?
4. Certificate not expired?
Does NOT raise exceptions - returns None on any failure.
Caller decides whether to fall back to other auth methods.
AUDIT: Logs all authentication attempts (success and failure) to AuditLog.
"""
from shopdb.api import Asset, AuditLog
from datetime import datetime, timezone
fingerprint = get_client_cert_fingerprint()
if not fingerprint:
return None
# Look up asset by cert fingerprint
asset = Asset.query.filter_by(client_cert_fingerprint=fingerprint).first()
if not asset:
current_app.logger.warning(
f'[client_cert] Unknown fingerprint: {fingerprint[:16]}... '
f'(not associated with any asset)')
# Audit: Failed authentication attempt with unknown fingerprint
try:
AuditLog.log(
action='auth.cert.unknown',
userid=None,
details=f'Unknown fingerprint: {fingerprint[:16]}...')
except Exception:
pass # Don't fail authentication on audit failure
return None
# Constant-time comparison to prevent timing attacks. Database lookup already
# happened (timing leak there is unavoidable with indexed lookups), but this
# prevents attackers from using response timing to confirm partial fingerprint
# matches during the final validation.
if not hmac.compare_digest(asset.client_cert_fingerprint, fingerprint):
current_app.logger.warning(
f'[client_cert] Fingerprint mismatch for asset {asset.assetid} '
f'(database vs request)')
return None
# Check certificate revocation (cert_revoked_at is set when admin revokes)
if hasattr(asset, 'cert_revoked_at') and asset.cert_revoked_at:
current_app.logger.warning(
f'[client_cert] Revoked certificate for asset {asset.assetid} '
f'(revoked {asset.cert_revoked_at})')
# Audit: Attempt to use revoked certificate
try:
AuditLog.log(
action='auth.cert.revoked',
userid=None,
entityid=asset.assetid,
entityname=asset.assetnumber,
details=f'Revoked at {asset.cert_revoked_at}: '
f'{asset.cert_revocation_reason or "no reason"}')
except Exception:
pass
return None
# Check certificate expiration
if asset.cert_expires_at:
now = datetime.now(timezone.utc).replace(tzinfo=None)
if asset.cert_expires_at < now:
current_app.logger.warning(
f'[client_cert] Expired certificate for asset {asset.assetid} '
f'(expired {asset.cert_expires_at})')
# Audit: Attempt to use expired certificate
try:
AuditLog.log(
action='auth.cert.expired',
userid=None,
entityid=asset.assetid,
entityname=asset.assetnumber,
details=f'Expired {asset.cert_expires_at}')
except Exception:
pass
return None
current_app.logger.info(
f'[client_cert] Authenticated asset {asset.assetid} '
f'(number={asset.assetnumber}) via certificate {fingerprint[:16]}...')
# Audit: Successful certificate authentication
try:
AuditLog.log(
action='auth.cert.success',
userid=None,
entityid=asset.assetid,
entityname=asset.assetnumber,
details=f'Cert fingerprint: {fingerprint[:16]}...')
except Exception:
pass
return asset