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,19 @@ public sealed class AgentConfig
|
||||
[JsonPropertyName("apikey")] public string ApiKey { get; set; } = "";
|
||||
[JsonPropertyName("cadenceseconds")] public int CadenceSeconds { get; set; } = 900;
|
||||
|
||||
// Optional client certificate thumbprint for mTLS authentication. When set,
|
||||
// the agent attaches the certificate from LocalMachine\My to HTTPS requests.
|
||||
// The server validates the fingerprint against its database. Empty = use
|
||||
// X-API-Key only (dual-auth during rollout).
|
||||
[JsonPropertyName("clientcertthumbprint")] public string? ClientCertThumbprint { get; set; }
|
||||
|
||||
// Optional server certificate thumbprint for certificate pinning. When set,
|
||||
// the agent validates that the server's TLS certificate matches this exact
|
||||
// thumbprint (SHA-1, hex, no separators). Blocks man-in-the-middle attacks
|
||||
// in air-gapped environments with self-signed certificates. Empty = accept
|
||||
// all server certificates (insecure, existing behavior for rollout).
|
||||
[JsonPropertyName("servercertthumbprint")] public string? ServerCertThumbprint { get; set; }
|
||||
|
||||
// Jitter spreads a fleet's reports so they do not arrive in lockstep
|
||||
// (thundering herd). Actual delay = cadence +/- up to jitterseconds.
|
||||
[JsonPropertyName("jitterseconds")] public int JitterSeconds { get; set; } = 60;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -32,7 +33,10 @@ public sealed class CollectorClient : IDisposable
|
||||
{
|
||||
this.logger = logger;
|
||||
this.maxAttempts = Math.Max(1, maxAttempts);
|
||||
http = new HttpClient
|
||||
|
||||
// Create HttpClient with optional client certificate for mTLS
|
||||
var handler = CreateHttpClientHandler(config);
|
||||
http = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri(config.BaseUrl.TrimEnd('/') + "/"),
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
@@ -40,6 +44,104 @@ public sealed class CollectorClient : IDisposable
|
||||
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
|
||||
}
|
||||
|
||||
// Creates HttpClientHandler with optional client certificate for mTLS and
|
||||
// optional server certificate pinning. If ClientCertThumbprint is configured,
|
||||
// attaches cert from LocalMachine\My. If ServerCertThumbprint is configured,
|
||||
// validates the server's certificate matches exactly (certificate pinning).
|
||||
private HttpClientHandler CreateHttpClientHandler(AgentConfig config)
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
|
||||
// Server certificate validation: pinning when configured, otherwise accept all
|
||||
handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) =>
|
||||
{
|
||||
if (cert == null)
|
||||
{
|
||||
logger.LogWarning("Server provided no certificate");
|
||||
return false;
|
||||
}
|
||||
|
||||
// If server cert pinning is configured, validate thumbprint match
|
||||
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
|
||||
{
|
||||
var actualThumbprint = cert.GetCertHashString();
|
||||
var expectedThumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "");
|
||||
|
||||
if (!string.Equals(actualThumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogError(
|
||||
"Server certificate thumbprint mismatch. Expected {Expected}, got {Actual}",
|
||||
expectedThumbprint, actualThumbprint);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.LogDebug("Server certificate validated via pinning: {Thumbprint}", actualThumbprint);
|
||||
return true;
|
||||
}
|
||||
|
||||
// No pinning configured: accept all (insecure, for rollout compatibility)
|
||||
return true;
|
||||
};
|
||||
|
||||
// Attach client certificate if configured
|
||||
if (!string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
|
||||
{
|
||||
var clientCert = FindClientCertificate(config.ClientCertThumbprint);
|
||||
if (clientCert != null)
|
||||
{
|
||||
handler.ClientCertificates.Add(clientCert);
|
||||
logger.LogInformation(
|
||||
"Attached client certificate: {Subject} (expires {Expires})",
|
||||
clientCert.Subject, clientCert.NotAfter);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning(
|
||||
"ClientCertThumbprint configured ({Thumbprint}) but certificate not found in LocalMachine\\My",
|
||||
config.ClientCertThumbprint);
|
||||
}
|
||||
}
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
||||
// Finds certificate in LocalMachine\My store by thumbprint (case-insensitive).
|
||||
// Returns null if not found. The certificate must have a private key.
|
||||
private X509Certificate2? FindClientCertificate(string thumbprint)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
|
||||
var certs = store.Certificates.Find(
|
||||
X509FindType.FindByThumbprint,
|
||||
thumbprint.Trim(),
|
||||
validOnly: false);
|
||||
|
||||
if (certs.Count == 0)
|
||||
return null;
|
||||
|
||||
var cert = certs[0];
|
||||
if (!cert.HasPrivateKey)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Found certificate {Subject} but it has no private key",
|
||||
cert.Subject);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cert;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
"Failed to load client certificate with thumbprint {Thumbprint}",
|
||||
thumbprint);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CollectorResponse> SendAsync(
|
||||
EndpointReport report, CancellationToken token)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -40,6 +41,7 @@ public sealed class CommandClient : IDisposable
|
||||
{
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(1),
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
SslOptions = CreateSslOptions(config),
|
||||
};
|
||||
http = new HttpClient(handler, disposeHandler: true)
|
||||
{
|
||||
@@ -51,6 +53,85 @@ public sealed class CommandClient : IDisposable
|
||||
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
|
||||
}
|
||||
|
||||
// Creates SslClientAuthenticationOptions with optional client certificate and
|
||||
// optional server certificate pinning. If ServerCertThumbprint is configured,
|
||||
// validates the server's certificate matches exactly (certificate pinning).
|
||||
private System.Net.Security.SslClientAuthenticationOptions CreateSslOptions(AgentConfig config)
|
||||
{
|
||||
var options = new System.Net.Security.SslClientAuthenticationOptions
|
||||
{
|
||||
RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
|
||||
{
|
||||
if (cert == null)
|
||||
{
|
||||
logger.LogWarning("Server provided no certificate");
|
||||
return false;
|
||||
}
|
||||
|
||||
// If server cert pinning is configured, validate thumbprint match
|
||||
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
|
||||
{
|
||||
var x509Cert = cert as System.Security.Cryptography.X509Certificates.X509Certificate2
|
||||
?? new System.Security.Cryptography.X509Certificates.X509Certificate2(cert);
|
||||
var actualThumbprint = x509Cert.GetCertHashString();
|
||||
var expectedThumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "");
|
||||
|
||||
if (!string.Equals(actualThumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogError(
|
||||
"Server certificate thumbprint mismatch on command channel. Expected {Expected}, got {Actual}",
|
||||
expectedThumbprint, actualThumbprint);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.LogDebug("Server certificate validated via pinning on command channel: {Thumbprint}", actualThumbprint);
|
||||
return true;
|
||||
}
|
||||
|
||||
// No pinning configured: accept all (insecure, for rollout compatibility)
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
|
||||
{
|
||||
var clientCert = FindClientCertificate(config.ClientCertThumbprint);
|
||||
if (clientCert != null)
|
||||
{
|
||||
options.ClientCertificates = new X509CertificateCollection { clientCert };
|
||||
logger.LogInformation(
|
||||
"Attached client certificate to command channel: {Subject}",
|
||||
clientCert.Subject);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
// Finds certificate in LocalMachine\My store by thumbprint (case-insensitive).
|
||||
private X509Certificate2? FindClientCertificate(string thumbprint)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
|
||||
var certs = store.Certificates.Find(
|
||||
X509FindType.FindByThumbprint,
|
||||
thumbprint.Trim(),
|
||||
validOnly: false);
|
||||
|
||||
return certs.Count > 0 && certs[0].HasPrivateKey ? certs[0] : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex,
|
||||
"Failed to load client certificate with thumbprint {Thumbprint}",
|
||||
thumbprint);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Claim the next pending command for this hostname, or null when the server
|
||||
// returned 204 (nothing queued within the hold window). A 4xx other than 429
|
||||
// is surfaced so the worker can back off instead of hot-looping a bad key or
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -30,6 +31,7 @@ public sealed class FleetAlertClient : IDisposable
|
||||
{
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||||
SslOptions = CreateSslOptions(config),
|
||||
};
|
||||
http = new HttpClient(handler, disposeHandler: true)
|
||||
{
|
||||
@@ -39,6 +41,82 @@ public sealed class FleetAlertClient : IDisposable
|
||||
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
|
||||
}
|
||||
|
||||
// Creates SslClientAuthenticationOptions with optional client certificate and
|
||||
// optional server certificate pinning. If ServerCertThumbprint is configured,
|
||||
// validates the server's certificate matches exactly (certificate pinning).
|
||||
private System.Net.Security.SslClientAuthenticationOptions CreateSslOptions(AgentConfig config)
|
||||
{
|
||||
var options = new System.Net.Security.SslClientAuthenticationOptions
|
||||
{
|
||||
RemoteCertificateValidationCallback = (sender, cert, chain, errors) =>
|
||||
{
|
||||
if (cert == null)
|
||||
{
|
||||
logger.LogWarning("Server provided no certificate");
|
||||
return false;
|
||||
}
|
||||
|
||||
// If server cert pinning is configured, validate thumbprint match
|
||||
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
|
||||
{
|
||||
var x509Cert = cert as System.Security.Cryptography.X509Certificates.X509Certificate2
|
||||
?? new System.Security.Cryptography.X509Certificates.X509Certificate2(cert);
|
||||
var actualThumbprint = x509Cert.GetCertHashString();
|
||||
var expectedThumbprint = config.ServerCertThumbprint.Replace(":", "").Replace(" ", "");
|
||||
|
||||
if (!string.Equals(actualThumbprint, expectedThumbprint, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logger.LogError(
|
||||
"Server certificate thumbprint mismatch on fleet alert channel. Expected {Expected}, got {Actual}",
|
||||
expectedThumbprint, actualThumbprint);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.LogDebug("Server certificate validated via pinning on fleet alert channel: {Thumbprint}", actualThumbprint);
|
||||
return true;
|
||||
}
|
||||
|
||||
// No pinning configured: accept all (insecure, for rollout compatibility)
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.ClientCertThumbprint))
|
||||
{
|
||||
var clientCert = FindClientCertificate(config.ClientCertThumbprint);
|
||||
if (clientCert != null)
|
||||
{
|
||||
options.ClientCertificates = new X509CertificateCollection { clientCert };
|
||||
logger.LogInformation(
|
||||
"Attached client certificate to fleet alert channel: {Subject}",
|
||||
clientCert.Subject);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
// Finds certificate in LocalMachine\My store by thumbprint.
|
||||
private X509Certificate2? FindClientCertificate(string thumbprint)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var store = new X509Store(StoreName.My, StoreLocation.LocalMachine);
|
||||
store.Open(OpenFlags.ReadOnly);
|
||||
|
||||
var certs = store.Certificates.Find(
|
||||
X509FindType.FindByThumbprint,
|
||||
thumbprint.Trim(),
|
||||
validOnly: false);
|
||||
|
||||
return certs.Count > 0 && certs[0].HasPrivateKey ? certs[0] : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<FleetAlert>> FetchAlertsAsync(
|
||||
string hostname, CancellationToken token)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user