mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
fix(backend/security): pin client cert chain trust to embedded Intune root
CertificateValidator previously combined AllowUnknownCertificateAuthority
with a DN-substring scan ("Microsoft Intune"/"MDM Device CA" in
Subject/Issuer of any chain element). A self-signed leaf with that DN
satisfied the substring gate while the unknown-root flag masked the
untrusted-root status -> the X.509 trust gate was bypassable.
Switch to X509ChainTrustMode.CustomRootTrust with the embedded Intune
root in CustomTrustStore and the intermediate in ExtraStore. chain.Build()
now requires a signature link to a pinned root regardless of the OS
trust store, which keeps Linux/Flex Consumption working without the
self-signed bypass. Empty roots -> fail-closed.
Adds 7 unit tests including the self-signed-impersonating-Intune
regression test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using AutopilotMonitor.Functions.Security;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.Functions.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for CertificateValidator chain-trust enforcement.
|
||||||
|
///
|
||||||
|
/// The validator pins trust to embedded Intune root certs via X509ChainTrustMode.CustomRootTrust.
|
||||||
|
/// A self-signed leaf cert whose Subject contains "Microsoft Intune" must NOT validate -
|
||||||
|
/// guards against the prior bypass where AllowUnknownCertificateAuthority + DN-substring
|
||||||
|
/// matching let any forged self-signed cert pass.
|
||||||
|
/// </summary>
|
||||||
|
public class CertificateValidatorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ValidateCertificate_WithEmptyBase64_ReturnsInvalid()
|
||||||
|
{
|
||||||
|
var result = CertificateValidator.ValidateCertificate("");
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Contains("No certificate", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateCertificate_WithNull_ReturnsInvalid()
|
||||||
|
{
|
||||||
|
var result = CertificateValidator.ValidateCertificate(null);
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateCertificate_WithMalformedBase64_ReturnsInvalid()
|
||||||
|
{
|
||||||
|
var result = CertificateValidator.ValidateCertificate("not-base64!!!");
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateCertificate_WithSelfSignedCertImpersonatingIntune_ReturnsInvalid()
|
||||||
|
{
|
||||||
|
// Regression test: a self-signed leaf with Subject CN="Microsoft Intune MDM Device CA"
|
||||||
|
// and Client-Auth EKU previously passed validation under
|
||||||
|
// AllowUnknownCertificateAuthority + DN substring match. Under CustomRootTrust the
|
||||||
|
// chain has no signature link to a pinned Intune root and must be rejected.
|
||||||
|
var b64 = CreateSelfSignedCertBase64(
|
||||||
|
subjectCn: "Microsoft Intune MDM Device CA",
|
||||||
|
includeClientAuthEku: true);
|
||||||
|
|
||||||
|
var result = CertificateValidator.ValidateCertificate(b64);
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Contains("chain validation failed", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateCertificate_WithSelfSignedCertImpersonatingMdmDeviceCa_ReturnsInvalid()
|
||||||
|
{
|
||||||
|
// Second variant of the Subject-substring bypass.
|
||||||
|
var b64 = CreateSelfSignedCertBase64(
|
||||||
|
subjectCn: "MDM Device CA",
|
||||||
|
includeClientAuthEku: true);
|
||||||
|
|
||||||
|
var result = CertificateValidator.ValidateCertificate(b64);
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateCertificate_WithSelfSignedNonIntuneCert_ReturnsInvalid()
|
||||||
|
{
|
||||||
|
var b64 = CreateSelfSignedCertBase64(
|
||||||
|
subjectCn: "test-device.contoso.example",
|
||||||
|
includeClientAuthEku: true);
|
||||||
|
|
||||||
|
var result = CertificateValidator.ValidateCertificate(b64);
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateCertificate_WithExpiredSelfSignedCert_ReturnsInvalid()
|
||||||
|
{
|
||||||
|
var b64 = CreateSelfSignedCertBase64(
|
||||||
|
subjectCn: "Microsoft Intune MDM Device CA",
|
||||||
|
includeClientAuthEku: true,
|
||||||
|
notBefore: DateTimeOffset.UtcNow.AddYears(-2),
|
||||||
|
notAfter: DateTimeOffset.UtcNow.AddYears(-1));
|
||||||
|
|
||||||
|
var result = CertificateValidator.ValidateCertificate(b64);
|
||||||
|
|
||||||
|
Assert.False(result.IsValid);
|
||||||
|
Assert.Contains("expired", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateSelfSignedCertBase64(
|
||||||
|
string subjectCn,
|
||||||
|
bool includeClientAuthEku,
|
||||||
|
DateTimeOffset? notBefore = null,
|
||||||
|
DateTimeOffset? notAfter = null)
|
||||||
|
{
|
||||||
|
using var rsa = RSA.Create(2048);
|
||||||
|
var req = new CertificateRequest(
|
||||||
|
$"CN={subjectCn}",
|
||||||
|
rsa,
|
||||||
|
HashAlgorithmName.SHA256,
|
||||||
|
RSASignaturePadding.Pkcs1);
|
||||||
|
|
||||||
|
if (includeClientAuthEku)
|
||||||
|
{
|
||||||
|
// 1.3.6.1.5.5.7.3.2 = Client Authentication
|
||||||
|
req.CertificateExtensions.Add(
|
||||||
|
new X509EnhancedKeyUsageExtension(
|
||||||
|
new OidCollection { new Oid("1.3.6.1.5.5.7.3.2") },
|
||||||
|
critical: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
var nb = notBefore ?? DateTimeOffset.UtcNow.AddDays(-1);
|
||||||
|
var na = notAfter ?? DateTimeOffset.UtcNow.AddYears(1);
|
||||||
|
using var cert = req.CreateSelfSigned(nb, na);
|
||||||
|
|
||||||
|
return Convert.ToBase64String(cert.Export(X509ContentType.Cert));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,9 +15,9 @@ namespace AutopilotMonitor.Functions.Security
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Security measures:
|
/// Security measures:
|
||||||
/// - Full X.509 chain validation using X509Chain.Build()
|
/// - 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 revocation checking (Intune certificates have no CRL/OCSP endpoints)
|
||||||
/// - Validates entire chain contains Microsoft Intune CA
|
|
||||||
/// - Prevents self-signed certificate attacks
|
/// - Prevents self-signed certificate attacks
|
||||||
/// - Validates Enhanced Key Usage for Client Authentication
|
/// - Validates Enhanced Key Usage for Client Authentication
|
||||||
/// - In-memory cache for validated certificates (5 minute TTL)
|
/// - In-memory cache for validated certificates (5 minute TTL)
|
||||||
@@ -29,8 +29,13 @@ namespace AutopilotMonitor.Functions.Security
|
|||||||
private static readonly ConcurrentDictionary<string, CachedValidationResult> _validationCache = new();
|
private static readonly ConcurrentDictionary<string, CachedValidationResult> _validationCache = new();
|
||||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||||
|
|
||||||
// Lazily loaded Intune CA certs from embedded resources
|
// Lazily loaded Intune CA certs from embedded PEM resources, split by role:
|
||||||
private static readonly Lazy<X509Certificate2[]> _intuneCaCerts = new(LoadIntuneCaCertificates);
|
// roots -> pinned trust anchors (chain.ChainPolicy.CustomTrustStore)
|
||||||
|
// intermediates -> bridge candidates (chain.ChainPolicy.ExtraStore)
|
||||||
|
private static readonly Lazy<X509Certificate2[]> _intuneRootCerts =
|
||||||
|
new(() => LoadIntunePemBundle("intune-root-ca.pem"));
|
||||||
|
private static readonly Lazy<X509Certificate2[]> _intuneIntermediateCerts =
|
||||||
|
new(() => LoadIntunePemBundle("intune-intermediate-ca.pem"));
|
||||||
|
|
||||||
private class CachedValidationResult
|
private class CachedValidationResult
|
||||||
{
|
{
|
||||||
@@ -101,31 +106,50 @@ namespace AutopilotMonitor.Functions.Security
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Validate certificate chain (without revocation check - Intune certs have no CRL/OCSP)
|
// 2. Validate certificate chain against pinned Intune roots (no revocation - no CRL/OCSP).
|
||||||
//
|
//
|
||||||
// On Linux (Azure Functions flex consumption plan) the OS trust store does not contain
|
// CustomTrustStore + CustomRootTrust pins trust to the embedded Intune root cert(s)
|
||||||
// Microsoft Intune CAs. We load them from embedded PEM resources into ExtraStore and set
|
// only and ignores the OS trust store entirely - works on Linux/Flex Consumption
|
||||||
// AllowUnknownCertificateAuthority so chain.Build() can resolve the issuer without the OS
|
// (no machine store) and replaces the previous AllowUnknownCertificateAuthority
|
||||||
// store. The issuer identity is still enforced in step 3.
|
// workaround, which was bypassable by self-signed leaf certs whose Subject contained
|
||||||
|
// "Microsoft Intune".
|
||||||
|
var roots = _intuneRootCerts.Value;
|
||||||
|
var intermediates = _intuneIntermediateCerts.Value;
|
||||||
|
|
||||||
|
if (roots.Length == 0)
|
||||||
|
{
|
||||||
|
logger?.LogError("No embedded Intune root certificates loaded - failing closed");
|
||||||
|
return new CertificateValidationResult
|
||||||
|
{
|
||||||
|
IsValid = false,
|
||||||
|
ErrorMessage = "Server misconfiguration: no Intune trust anchors available",
|
||||||
|
Thumbprint = thumbprint
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
using var chain = new X509Chain();
|
using var chain = new X509Chain();
|
||||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||||
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain;
|
chain.ChainPolicy.RevocationFlag = X509RevocationFlag.EntireChain;
|
||||||
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
|
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
|
||||||
chain.ChainPolicy.VerificationTime = DateTime.UtcNow;
|
chain.ChainPolicy.VerificationTime = DateTime.UtcNow;
|
||||||
chain.ChainPolicy.UrlRetrievalTimeout = TimeSpan.FromSeconds(10);
|
chain.ChainPolicy.UrlRetrievalTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||||
|
|
||||||
// Load embedded Intune CA certs into ExtraStore
|
foreach (var root in roots)
|
||||||
var caCerts = _intuneCaCerts.Value;
|
chain.ChainPolicy.CustomTrustStore.Add(root);
|
||||||
logger?.LogDebug("Loading {Count} embedded Intune CA certificate(s) into chain ExtraStore", caCerts.Length);
|
foreach (var intermediate in intermediates)
|
||||||
foreach (var caCert in caCerts)
|
chain.ChainPolicy.ExtraStore.Add(intermediate);
|
||||||
chain.ChainPolicy.ExtraStore.Add(caCert);
|
|
||||||
|
logger?.LogDebug(
|
||||||
|
"Chain build with {RootCount} pinned Intune root(s) + {IntermediateCount} intermediate(s)",
|
||||||
|
roots.Length, intermediates.Length);
|
||||||
|
|
||||||
var chainValid = chain.Build(certificate);
|
var chainValid = chain.Build(certificate);
|
||||||
|
|
||||||
if (!chainValid)
|
if (!chainValid)
|
||||||
{
|
{
|
||||||
var chainErrors = string.Join(", ", chain.ChainStatus.Select(s => s.StatusInformation));
|
var chainErrors = string.Join(", ", chain.ChainStatus.Select(s => s.StatusInformation));
|
||||||
logger?.LogWarning($"Certificate chain validation failed: {chainErrors}");
|
logger?.LogWarning("Certificate chain validation failed: {ChainErrors}", chainErrors);
|
||||||
|
|
||||||
return new CertificateValidationResult
|
return new CertificateValidationResult
|
||||||
{
|
{
|
||||||
@@ -135,29 +159,12 @@ namespace AutopilotMonitor.Functions.Security
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Verify issuer chain contains Microsoft Intune CA
|
// chainValid == true under CustomRootTrust proves the leaf is transitively signed
|
||||||
var hasValidIssuer = chain.ChainElements
|
// by one of the pinned Intune roots. The previous Subject/Issuer DN substring check
|
||||||
.Cast<X509ChainElement>()
|
// is intentionally removed - it added no security on top of root pinning and would
|
||||||
.Any(element =>
|
// false-negative if Microsoft renames the CA.
|
||||||
element.Certificate.Issuer.Contains("Microsoft Intune", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
element.Certificate.Issuer.Contains("MDM Device CA", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
element.Certificate.Subject.Contains("Microsoft Intune", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
element.Certificate.Subject.Contains("MDM Device CA", StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (!hasValidIssuer)
|
// 3. Check Enhanced Key Usage for Client Authentication (1.3.6.1.5.5.7.3.2)
|
||||||
{
|
|
||||||
var chainInfo = string.Join(" -> ", chain.ChainElements.Cast<X509ChainElement>().Select(e => e.Certificate.Subject));
|
|
||||||
logger?.LogWarning($"Certificate chain does not contain Microsoft Intune CA: {chainInfo}");
|
|
||||||
|
|
||||||
return new CertificateValidationResult
|
|
||||||
{
|
|
||||||
IsValid = false,
|
|
||||||
ErrorMessage = $"Certificate not issued by Microsoft Intune CA",
|
|
||||||
Thumbprint = thumbprint
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Check Enhanced Key Usage for Client Authentication (1.3.6.1.5.5.7.3.2)
|
|
||||||
var hasClientAuth = certificate.Extensions
|
var hasClientAuth = certificate.Extensions
|
||||||
.OfType<X509EnhancedKeyUsageExtension>()
|
.OfType<X509EnhancedKeyUsageExtension>()
|
||||||
.Any(ext => ext.EnhancedKeyUsages
|
.Any(ext => ext.EnhancedKeyUsages
|
||||||
@@ -207,48 +214,40 @@ namespace AutopilotMonitor.Functions.Security
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads Intune CA certificates from embedded PEM resources.
|
/// Loads one or more X.509 certs from a PEM-bundle embedded resource under
|
||||||
/// Files: Security/Certificates/intune-intermediate-ca.pem
|
/// Security/Certificates/. Roles are filename-segregated:
|
||||||
/// Security/Certificates/intune-root-ca.pem
|
/// intune-root-ca.pem -> pinned trust anchors (CustomTrustStore)
|
||||||
|
/// intune-intermediate-ca.pem -> chain bridge candidates (ExtraStore)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static X509Certificate2[] LoadIntuneCaCertificates()
|
private static X509Certificate2[] LoadIntunePemBundle(string fileName)
|
||||||
{
|
{
|
||||||
|
var resourceName = $"AutopilotMonitor.Functions.Security.Certificates.{fileName}";
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
var resourceNames = new[]
|
|
||||||
|
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||||
|
if (stream == null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
var pem = reader.ReadToEnd();
|
||||||
|
|
||||||
|
if (pem.Contains("PLACEHOLDER_REPLACE_WITH_BASE64"))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
// PEM may contain multiple cert blocks (e.g. cross-signed roots); parse each.
|
||||||
|
const string begin = "-----BEGIN CERTIFICATE-----";
|
||||||
|
const string end = "-----END CERTIFICATE-----";
|
||||||
|
var certs = new List<X509Certificate2>();
|
||||||
|
int idx = 0;
|
||||||
|
while ((idx = pem.IndexOf(begin, idx, StringComparison.Ordinal)) >= 0)
|
||||||
{
|
{
|
||||||
"AutopilotMonitor.Functions.Security.Certificates.intune-intermediate-ca.pem",
|
var endIdx = pem.IndexOf(end, idx, StringComparison.Ordinal);
|
||||||
"AutopilotMonitor.Functions.Security.Certificates.intune-root-ca.pem"
|
if (endIdx < 0) break;
|
||||||
};
|
var block = pem.Substring(idx, endIdx + end.Length - idx);
|
||||||
|
try { certs.Add(X509Certificate2.CreateFromPem(block)); }
|
||||||
List<X509Certificate2> certs = [];
|
catch { /* malformed block - skip; chain.Build() fails closed if no roots remain */ }
|
||||||
|
idx = endIdx + end.Length;
|
||||||
foreach (var resourceName in resourceNames)
|
|
||||||
{
|
|
||||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
|
||||||
if (stream == null)
|
|
||||||
{
|
|
||||||
// Resource not found - not fatal, chain validation will fall back to OS store
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
using var reader = new StreamReader(stream);
|
|
||||||
var pem = reader.ReadToEnd();
|
|
||||||
|
|
||||||
// Skip placeholder files that haven't been filled with a real cert yet
|
|
||||||
if (pem.Contains("PLACEHOLDER_REPLACE_WITH_BASE64"))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var cert = X509Certificate2.CreateFromPem(pem);
|
|
||||||
certs.Add(cert);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Malformed PEM - skip silently, chain validation continues with OS store
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return certs.ToArray();
|
return certs.ToArray();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user