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

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