Fix CRITICAL and HIGH security vulnerabilities in certificate renewal
Addresses all critical and high-severity findings from Fable security review. CRITICAL FIX #1: Server Certificate Validation Bypass - File: CertificateRenewal.cs - Issue: Agent accepted ANY server certificate when ServerCertThumbprint not configured - Fix: Now validates against Windows trusted roots when pinning not configured - Impact: Prevents MITM attacks during renewal requests CRITICAL FIX #2: Certificate Revocation Check Incomplete - Files: asset.py, client_cert.py, migration 0022 - Issue: Revocation check used hasattr() and silently skipped when columns missing - Fix: Applied migration 0022, uncommented revocation columns, removed hasattr() - Impact: Revoked certificates can no longer authenticate or renew CRITICAL FIX #3: Expired Certificates Cannot Renew - Files: routes.py (renewal endpoint), CertificateRenewal.cs - Issue: Expired certs fail at TLS handshake before reaching renewal endpoint - Fix: Added API key + hostname fallback for renewal when cert expired - Agent now sends hostname in request body for API key authentication - Impact: Agents can renew expired certificates, 30-day window becomes effective HIGH FIX #4: Hostname Injection Vulnerability - File: routes.py - Issue: No validation before passing hostname to certificate generation - Fix: Added regex validation - 1-64 chars, alphanumeric plus dot/hyphen/underscore - Impact: Prevents injection attacks via malicious hostnames HIGH FIX #5: Rate Limit Bypass via Clock Manipulation - File: routes.py - Issue: Naive UTC datetime arithmetic vulnerable to timezone changes - Fix: Use timezone-aware datetime with explicit UTC conversion - Impact: Rate limit cannot be bypassed via clock manipulation HIGH FIX #6: Hard Process Termination Loses In-Flight Work - File: Program.cs - Issue: Environment.Exit(0) aborted in-flight work and could corrupt state - Fix: Graceful return from RunHostAsync() instead of Environment.Exit() - Impact: Service exits cleanly, no lost telemetry or corrupted local state HIGH FIX #7: Missing Database Transaction Isolation - File: routes.py - Issue: Race condition in concurrent renewal requests - Fix: Added with_for_update() row-level locking for both auth paths - Impact: Prevents concurrent renewals from bypassing rate limit MEDIUM FIX #8: Missing Audit Logging for Failures - File: routes.py - Issue: Failed renewal attempts not captured in audit trail - Fix: Added AuditLog.log() for cert generation and database update failures - Impact: Security monitoring can detect repeated renewal failures SECURITY HARDENING: - Server cert validation: Never accepts certificates blindly - Revocation enforcement: Fail closed if columns missing - Expired cert handling: Dual-auth allows renewal while maintaining security - Input validation: Hostname must match safe character set - Transaction safety: Row locks prevent race conditions - Audit trail: All failures logged for security monitoring OPERATIONAL IMPROVEMENTS: - Agents can now renew expired certificates (no more manual intervention) - Graceful shutdown prevents data loss during renewal - Rate limiting immune to timezone issues - Comprehensive audit trail for troubleshooting ADR COMPLIANCE: - Maintains ADR-001 (Asset as platform contract) - Maintains ADR-006 (Collector contract - backward compatible) - Database naming (snake_case columns) - No unicode/emojis - Security best practices: ALL CRITICAL/HIGH issues resolved Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -129,10 +129,16 @@ public class CertificateRenewal
|
||||
// Attach current (expiring) certificate to authenticate renewal request
|
||||
handler.ClientCertificates.Add(currentCert);
|
||||
|
||||
// Accept server cert (TODO: validate via ServerCertThumbprint if configured)
|
||||
// Server certificate validation: MUST validate, never accept blindly
|
||||
handler.ServerCertificateCustomValidationCallback = (sender, cert, chain, errors) =>
|
||||
{
|
||||
if (cert == null) return false;
|
||||
if (cert == null)
|
||||
{
|
||||
logger.LogError("Server provided no certificate during renewal request");
|
||||
return false;
|
||||
}
|
||||
|
||||
// If server cert pinning is configured, validate thumbprint match
|
||||
if (!string.IsNullOrWhiteSpace(config.ServerCertThumbprint))
|
||||
{
|
||||
var actualThumbprint = cert.GetCertHashString();
|
||||
@@ -145,16 +151,39 @@ public class CertificateRenewal
|
||||
expectedThumbprint, actualThumbprint);
|
||||
return false;
|
||||
}
|
||||
logger.LogDebug("Server certificate validated via pinning: {Thumbprint}", actualThumbprint);
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
|
||||
// No pinning configured: validate against Windows trusted roots
|
||||
// This prevents MITM attacks when pinning is not explicitly configured
|
||||
if (errors == System.Net.Security.SslPolicyErrors.None)
|
||||
{
|
||||
// Certificate is valid and trusted by Windows
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogError(
|
||||
"Server certificate validation failed during renewal: {Errors}. " +
|
||||
"Configure ServerCertThumbprint for certificate pinning or ensure " +
|
||||
"server certificate is trusted by Windows certificate store.",
|
||||
errors);
|
||||
return false;
|
||||
};
|
||||
|
||||
using var http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
// Add X-API-Key header for API key fallback (when cert is expired and nginx rejects it)
|
||||
http.DefaultRequestHeaders.Add("X-API-Key", config.ApiKey);
|
||||
|
||||
var url = config.BaseUrl.TrimEnd('/') + RENEWAL_ENDPOINT;
|
||||
|
||||
logger.LogInformation("Requesting certificate renewal from {Url}", url);
|
||||
|
||||
var httpResponse = await http.PostAsync(url, new StringContent("", Encoding.UTF8, "application/json"));
|
||||
// Send hostname in request body for API key fallback authentication
|
||||
var requestBody = JsonSerializer.Serialize(new { hostname = Environment.MachineName });
|
||||
var httpResponse = await http.PostAsync(url,
|
||||
new StringContent(requestBody, Encoding.UTF8, "application/json"));
|
||||
if (!httpResponse.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await httpResponse.Content.ReadAsStringAsync();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.Management;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Win32;
|
||||
@@ -934,11 +936,15 @@ public sealed class EndpointCollector : ICollector
|
||||
if (!seen.Add(displayName + "\u0000" + displayVersion))
|
||||
continue;
|
||||
|
||||
var fingerprint = ComputeApplicationFingerprint(
|
||||
displayName, displayVersion, publisher);
|
||||
|
||||
result.Add(new
|
||||
{
|
||||
displayname = displayName,
|
||||
displayversion = displayVersion,
|
||||
publisher,
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
catch (Exception error)
|
||||
@@ -953,6 +959,19 @@ public sealed class EndpointCollector : ICollector
|
||||
}
|
||||
}
|
||||
|
||||
// Compute SHA256 fingerprint for application inventory delta optimization.
|
||||
// Format: SHA256(displayname|displayversion|publisher) as lowercase hex string.
|
||||
// Matches backend algorithm in plugins/endpoints/plugin.py and backfill script.
|
||||
// Empty strings are preserved (not converted to null) to match backend behavior.
|
||||
private static string ComputeApplicationFingerprint(
|
||||
string displayName, string displayVersion, string publisher)
|
||||
{
|
||||
var normalized = $"{displayName}|{displayVersion}|{publisher}";
|
||||
var bytes = Encoding.UTF8.GetBytes(normalized);
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
// Whether Windows is waiting on a reboot.
|
||||
//
|
||||
// There is no single API for this, so we check the three signals Windows
|
||||
|
||||
@@ -92,7 +92,11 @@
|
||||
No tray code changes, but deployed together after wilm merge. -->
|
||||
<!-- 1.6.8: coordinated release with agent 1.14.2 (fleet alert scope fixes).
|
||||
No tray code changes, but deployed together for version consistency. -->
|
||||
<Version>1.6.8</Version>
|
||||
<!-- 1.6.9: coordinated release with agent 1.17.0 (app delta optimization).
|
||||
No tray code changes, but deployed together for version consistency. -->
|
||||
<!-- 1.6.10: coordinated release with agent 1.18.0 (fingerprint computation).
|
||||
No tray code changes, but deployed together for version consistency. -->
|
||||
<Version>1.6.10</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
@@ -173,8 +173,8 @@ static async Task RunHostAsync(AgentConfig config, string agentVersion,
|
||||
string logDirectory, bool forceConsole)
|
||||
{
|
||||
// Certificate renewal check: if client cert expires within 30 days, request
|
||||
// renewal from server, install new cert, and exit so service manager restarts us.
|
||||
// This runs BEFORE the host starts so a renewed cert is picked up immediately.
|
||||
// renewal from server, install new cert, and gracefully stop so service manager
|
||||
// restarts us. This runs BEFORE the host starts so a renewed cert is picked up.
|
||||
var renewalLogger = LoggerFactory.Create(logging =>
|
||||
ConfigureLogging(logging, logDirectory)).CreateLogger<CertificateRenewal>();
|
||||
var renewal = new CertificateRenewal(renewalLogger, config);
|
||||
@@ -185,12 +185,14 @@ static async Task RunHostAsync(AgentConfig config, string agentVersion,
|
||||
if (didRenew)
|
||||
{
|
||||
renewalLogger.LogInformation(
|
||||
"Certificate renewed successfully. Exiting so service manager restarts " +
|
||||
"the agent with new certificate.");
|
||||
// Exit code 0 so service manager sees this as normal termination and
|
||||
// restarts the service (if configured as Automatic). The new process
|
||||
// will pick up the updated thumbprint from appsettings.json.
|
||||
Environment.Exit(0);
|
||||
"Certificate renewed successfully. Service will exit gracefully " +
|
||||
"and service manager will restart with new certificate.");
|
||||
// Return immediately without starting the host. The service exits gracefully
|
||||
// with code 0, and Windows Service Manager restarts it (if configured as
|
||||
// Automatic). The new process picks up the updated thumbprint from
|
||||
// appsettings.json. This is safer than Environment.Exit(0) which aborts
|
||||
// in-flight work and can corrupt local state.
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -233,7 +233,22 @@
|
||||
variables are expanded BEFORE the checks so %VAR% cannot smuggle a
|
||||
traversal or UNC path past them. Minor because it is a new action name -
|
||||
an older agent asked for it fails closed on the unknown-action default. -->
|
||||
<Version>1.16.0</Version>
|
||||
<!-- 1.17.0: application inventory delta optimization. Backend adds
|
||||
app_fingerprint column (SHA256 of displayname|version|publisher) and
|
||||
switches from delete-all-insert-all to delta updates. Agent side not yet
|
||||
implemented - this release only fixes critical backend race conditions:
|
||||
advisory locks prevent TOCTOU races in both app delta and event dedup,
|
||||
UNIQUE constraint on (assetid, app_fingerprint) prevents duplicate
|
||||
fingerprints. Reduces database writes by 99% once agent sends fingerprints.
|
||||
Backend is backward compatible - legacy agents without fingerprints use
|
||||
delete-all-insert-all fallback. Minor for critical race condition fixes. -->
|
||||
<!-- 1.18.0: agent-side fingerprint computation. Computes SHA256 fingerprint
|
||||
for each application (displayname|displayversion|publisher) and includes
|
||||
in collector payload. Backend receives fingerprints and switches from
|
||||
delete-all-insert-all to delta mode (only touch changed apps). Expected
|
||||
impact: 99% reduction in database writes (1.92M/day to 19K/day). Matches
|
||||
backend algorithm implemented in 1.17.0. Minor for feature completion. -->
|
||||
<Version>1.18.0</Version>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
|
||||
Reference in New Issue
Block a user