diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CertificateValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CertificateValidatorTests.cs index 3ae242a2..1d65ea3a 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/CertificateValidatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CertificateValidatorTests.cs @@ -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. /// 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)); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs index da5f07f8..8e89a0a1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/CertificateValidator.cs @@ -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);