From cb2eb02ce14aa28ea596cad932cb36ca5ae193d0 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Wed, 21 Oct 2026 15:10:54 +0200 Subject: [PATCH] Resolve relative DEV_PROXY_CERT_PATH against config folder. Closes dotnet/dev-proxy#1947 The Kestrel CertificateAuthority ignored a relative DEV_PROXY_CERT_PATH and fell back to /rootCert.pfx. The Docker images set DEV_PROXY_CERT_PATH=rootCert/rootCert.pfx, so the root certificate was written outside the mounted rootCert volume. Resolve relative values against the config folder, matching v3.3.1 (Titanium) behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6bef9494-04d4-42fb-bd36-c84a4b6ed44f --- .../CertificateAuthorityTests.cs | 36 +++++++++++++++++++ .../Internal/CertificateAuthority.cs | 29 ++++++++++++--- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs b/DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs index f8687660..7d8aa699 100644 --- a/DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs +++ b/DevProxy.Proxy.Kestrel.Tests/CertificateAuthorityTests.cs @@ -45,6 +45,42 @@ public void Dispose() } } + [Theory] + [InlineData(null)] + [InlineData("")] + public void ResolveRootPfxPath_NoEnvPath_UsesDefaultFileInConfigDirectory(string? envPath) + { + Assert.Equal(_rootPath, CertificateAuthority.ResolveRootPfxPath(envPath, _dir)); + } + + [Fact] + public void ResolveRootPfxPath_RelativeEnvPath_ResolvesAgainstConfigDirectory() + { + var relative = Path.Combine("rootCert", "rootCert.pfx"); + + Assert.Equal( + Path.Combine(_dir, "rootCert", "rootCert.pfx"), + CertificateAuthority.ResolveRootPfxPath(relative, _dir)); + } + + [Fact] + public void ResolveRootPfxPath_RootedEnvPath_IsUsedAsIs() + { + var rooted = Path.Combine(_dir, "elsewhere", "custom.pfx"); + + Assert.Equal(rooted, CertificateAuthority.ResolveRootPfxPath(rooted, Path.Combine(_dir, "config"))); + } + + [Fact] + public void RelativeEnvPath_PersistsRootInSubfolder() + { + var rootPath = CertificateAuthority.ResolveRootPfxPath("rootCert/rootCert.pfx", _dir); + + using var ca = new CertificateAuthority(rootPath, _leafDir); + + Assert.True(File.Exists(Path.Combine(_dir, "rootCert", "rootCert.pfx"))); + } + [Fact] public void FirstRun_CreatesValidRoot_AndPersistsToDisk() { diff --git a/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs b/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs index 084ff0df..977f2e83 100644 --- a/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs +++ b/DevProxy.Proxy.Kestrel/Internal/CertificateAuthority.cs @@ -18,7 +18,8 @@ namespace DevProxy.Proxy.Kestrel.Internal; /// /// The on-disk layout intentionally mirrors the Titanium engine's /// CertificateDiskCache: the root lives at <configDir>/rootCert.pfx -/// (overridable via DEV_PROXY_CERT_PATH) and leaves at +/// (overridable via DEV_PROXY_CERT_PATH; relative values resolve against +/// <configDir>) and leaves at /// <configDir>/crts/<host>.pfx, both PKCS#12 with an empty password. /// Because the format + path match, this CA will load a root that the Titanium /// engine already created and the OS already trusts — so existing users get @@ -82,15 +83,33 @@ public static CertificateAuthority CreateDefault(ILogger? logger = null) { var configDirectory = ResolveConfigDirectory(); - var envPath = Environment.GetEnvironmentVariable("DEV_PROXY_CERT_PATH"); - var rootPfxPath = !string.IsNullOrEmpty(envPath) && Path.IsPathRooted(envPath) - ? envPath - : Path.Combine(configDirectory, RootCertFileName); + var rootPfxPath = ResolveRootPfxPath( + Environment.GetEnvironmentVariable("DEV_PROXY_CERT_PATH"), + configDirectory); var leafDirectory = Path.Combine(configDirectory, LeafDirectoryName); return new CertificateAuthority(rootPfxPath, leafDirectory, logger); } + /// + /// Resolves the root certificate path like Titanium's + /// CertificateDiskCache.GetRootCertificatePath: a rooted + /// DEV_PROXY_CERT_PATH is used as-is, a relative one is resolved + /// against the config directory, and an empty one falls back to + /// <configDir>/rootCert.pfx. + /// + internal static string ResolveRootPfxPath(string? envPath, string configDirectory) + { + if (string.IsNullOrEmpty(envPath)) + { + return Path.Combine(configDirectory, RootCertFileName); + } + + return Path.IsPathRooted(envPath) + ? envPath + : Path.Combine(configDirectory, envPath); + } + /// The root CA certificate clients must trust to allow interception. public X509Certificate2 RootCertificate => _ca;