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:
@@ -13,6 +13,7 @@ from flask import Blueprint, request, current_app, g
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import Asset, Application
|
||||
from shopdb.core.models.apitoken import ApiToken, TOKEN_SECRET_PREFIX
|
||||
from shopdb.core.auth.client_cert import authenticate_by_client_cert
|
||||
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
|
||||
@@ -241,11 +242,21 @@ def generic_collect(pluginname):
|
||||
|
||||
plugin, schema = plugins[pluginname]
|
||||
|
||||
# Per-plugin env key (fallback) OR a collector-scoped managed token. Header
|
||||
# only (querystring fallback dropped, see require_api_key).
|
||||
error = _check_collector_auth(_plugin_api_key(pluginname), pluginname)
|
||||
if error is not None:
|
||||
return error
|
||||
# Dual-auth: try client certificate first (per-endpoint mTLS), fall back to
|
||||
# per-plugin env key or collector-scoped managed token. Header only
|
||||
# (querystring fallback dropped, see require_api_key).
|
||||
from shopdb.core.auth.client_cert import authenticate_by_client_cert
|
||||
cert_asset = authenticate_by_client_cert()
|
||||
if cert_asset:
|
||||
g.cert_authenticated_asset_id = cert_asset.assetid
|
||||
current_app.logger.debug(
|
||||
'[collector] Authenticated via client cert for asset %d',
|
||||
cert_asset.assetid)
|
||||
else:
|
||||
# No cert or cert invalid: fall back to API key
|
||||
error = _check_collector_auth(_plugin_api_key(pluginname), pluginname)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
payload = request.get_json(silent=True)
|
||||
if not payload or not isinstance(payload, dict):
|
||||
|
||||
0
shopdb/core/auth/__init__.py
Normal file
0
shopdb/core/auth/__init__.py
Normal file
211
shopdb/core/auth/client_cert.py
Normal file
211
shopdb/core/auth/client_cert.py
Normal 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
|
||||
@@ -139,6 +139,34 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
||||
# Notes
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Client certificate authentication (endpoints plugin)
|
||||
client_cert_fingerprint = db.Column(
|
||||
db.String(64),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment='SHA1 or SHA256 fingerprint of client certificate for mTLS auth'
|
||||
)
|
||||
cert_issued_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=True,
|
||||
comment='When the client certificate was issued'
|
||||
)
|
||||
cert_expires_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=True,
|
||||
comment='When the certificate expires'
|
||||
)
|
||||
cert_revoked_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=True,
|
||||
comment='When the certificate was revoked (null = not revoked)'
|
||||
)
|
||||
cert_revocation_reason = db.Column(
|
||||
db.String(255),
|
||||
nullable=True,
|
||||
comment='Why the certificate was revoked (admin note)'
|
||||
)
|
||||
|
||||
# Relationships
|
||||
assettype = db.relationship('AssetType', backref='assets')
|
||||
status = db.relationship('AssetStatus', backref='assets')
|
||||
|
||||
Reference in New Issue
Block a user