mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Client-certificate chain building never downloads an issuer
The platform forwards whatever certificate the TLS client presented, so an unauthenticated caller can hand in a leaf signed by a CA it made up. The chain engine then fetched the leaf's AIA caIssuers URL before rejecting it: an attacker-directed GET from the backend, and a worker blocked for the 10 s retrieval timeout. Every legitimate issuer is embedded and Intune certificates carry no AIA, so downloads are now disabled and the unused retrieval timeout is gone. The regression test signs a leaf with a CA that is neither embedded nor sent and asserts that the listener behind its AIA URL sees no connection; a self-signed leaf never reaches that path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
@@ -13,6 +15,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// must validate successfully. This guards against rollouts where the embedded root/intermediate
|
||||
/// bundle drifts from the chain Microsoft is actually issuing.
|
||||
/// - Negative path: self-signed leaf certs (with or without Intune-shaped DNs) must NOT validate.
|
||||
/// - No network I/O: a leaf from an unknown CA must be rejected without fetching its AIA URL.
|
||||
/// </summary>
|
||||
public class CertificateValidatorTests
|
||||
{
|
||||
@@ -131,6 +134,31 @@ public class CertificateValidatorTests
|
||||
Assert.Contains("expired", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateCertificate_WithUnknownIssuerAndAiaUrl_NeverFetchesTheIssuer()
|
||||
{
|
||||
// The caller mints the leaf, so the AIA caIssuers URL points wherever they like. A
|
||||
// self-signed leaf never reaches the issuer-download path; it takes a leaf signed by a
|
||||
// CA that is neither embedded nor sent.
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var b64 = CreateLeafFromUnknownCaBase64($"http://127.0.0.1:{port}/{Guid.NewGuid():N}.cer");
|
||||
|
||||
var result = CertificateValidator.ValidateCertificate(b64);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains("chain validation failed", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(listener.Pending(), "Chain building connected to the certificate's AIA URL");
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveSamplePath()
|
||||
{
|
||||
var assemblyDir = Path.GetDirectoryName(typeof(CertificateValidatorTests).Assembly.Location)!;
|
||||
@@ -165,4 +193,24 @@ public class CertificateValidatorTests
|
||||
|
||||
return Convert.ToBase64String(cert.Export(X509ContentType.Cert));
|
||||
}
|
||||
|
||||
private static string CreateLeafFromUnknownCaBase64(string aiaCaIssuersUrl)
|
||||
{
|
||||
using var caKey = RSA.Create(2048);
|
||||
var caReq = new CertificateRequest("CN=Unknown Test CA", caKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
caReq.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
||||
using var ca = caReq.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
|
||||
|
||||
using var leafKey = RSA.Create(2048);
|
||||
var leafReq = new CertificateRequest("CN=test-device.contoso.example", leafKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
leafReq.CertificateExtensions.Add(
|
||||
new X509EnhancedKeyUsageExtension(
|
||||
new OidCollection { new Oid("1.3.6.1.5.5.7.3.2") },
|
||||
critical: false));
|
||||
leafReq.CertificateExtensions.Add(
|
||||
new X509AuthorityInformationAccessExtension(ocspUris: null, caIssuersUris: new[] { aiaCaIssuersUrl }));
|
||||
using var leaf = leafReq.Create(ca, DateTimeOffset.UtcNow.AddHours(-1), DateTimeOffset.UtcNow.AddDays(30), RandomNumberGenerator.GetBytes(16));
|
||||
|
||||
return Convert.ToBase64String(leaf.Export(X509ContentType.Cert));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
/// - X.509 chain trust pinned to embedded Intune root certificate(s) via
|
||||
/// X509ChainTrustMode.CustomRootTrust (OS trust store is ignored)
|
||||
/// - No revocation checking (Intune certificates have no CRL/OCSP endpoints)
|
||||
/// - No network I/O during chain building: issuer downloads (AIA) are disabled
|
||||
/// - Prevents self-signed certificate attacks
|
||||
/// - Validates Enhanced Key Usage for Client Authentication
|
||||
/// - In-memory cache for validated certificates (5 minute TTL)
|
||||
@@ -200,8 +201,11 @@ namespace AutopilotMonitor.Functions.Security
|
||||
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain;
|
||||
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
|
||||
chain.ChainPolicy.VerificationTime = DateTime.UtcNow;
|
||||
chain.ChainPolicy.UrlRetrievalTimeout = TimeSpan.FromSeconds(10);
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
// The leaf, and with it any AIA issuer URL, comes from an unauthenticated caller.
|
||||
// Every legitimate issuer is embedded below, so the chain engine must never
|
||||
// download one: a fetch would be an attacker-directed request from the backend.
|
||||
chain.ChainPolicy.DisableCertificateDownloads = true;
|
||||
|
||||
foreach (var root in roots)
|
||||
chain.ChainPolicy.CustomTrustStore.Add(root);
|
||||
|
||||
Reference in New Issue
Block a user