diff --git a/src/Runner.Common/HttpClientHandlerFactory.cs b/src/Runner.Common/HttpClientHandlerFactory.cs index 4e0a88db718..badadb9690d 100644 --- a/src/Runner.Common/HttpClientHandlerFactory.cs +++ b/src/Runner.Common/HttpClientHandlerFactory.cs @@ -1,5 +1,7 @@ using System; +using System.IO; using System.Net.Http; +using System.Security.Cryptography.X509Certificates; using GitHub.Runner.Sdk; namespace GitHub.Runner.Common @@ -21,7 +23,60 @@ public HttpClientHandler CreateClientHandler(RunnerWebProxy webProxy) client.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; } + // Configure mTLS client certificate for proxy authentication + if (!string.IsNullOrEmpty(webProxy.HttpsProxyClientCert)) + { + var clientCert = LoadClientCertificate( + webProxy.HttpsProxyClientCert, + webProxy.HttpsProxyClientKey); + if (clientCert != null) + { + client.ClientCertificates.Add(clientCert); + } + } + return client; } + + private X509Certificate2 LoadClientCertificate(string certPath, string keyPath) + { + try + { + if (!File.Exists(certPath)) + { + Trace.Warning($"Client certificate file not found: {certPath}"); + return null; + } + + // If key path is provided separately, load cert and key from separate files + if (!string.IsNullOrEmpty(keyPath)) + { + if (!File.Exists(keyPath)) + { + Trace.Warning($"Client key file not found: {keyPath}"); + return null; + } + + // Load certificate and private key from separate PEM files + var certPem = File.ReadAllText(certPath); + var keyPem = File.ReadAllText(keyPath); + var cert = X509Certificate2.CreateFromPem(certPem, keyPem); + + // On Windows, we need to export and re-import to make the certificate usable + // with SslStream/HttpClient + return new X509Certificate2(cert.Export(X509ContentType.Pfx)); + } + else + { + // Assume the cert file contains both certificate and key (PFX/PKCS12 format) + return new X509Certificate2(certPath); + } + } + catch (Exception ex) + { + Trace.Warning($"Failed to load client certificate: {ex.Message}"); + return null; + } + } } } diff --git a/src/Runner.Sdk/RunnerWebProxy.cs b/src/Runner.Sdk/RunnerWebProxy.cs index 4c3c92a5502..a0505daaefd 100644 --- a/src/Runner.Sdk/RunnerWebProxy.cs +++ b/src/Runner.Sdk/RunnerWebProxy.cs @@ -22,6 +22,9 @@ public class RunnerWebProxy : IWebProxy private string _httpsProxyUsername; private string _httpsProxyPassword; private string _noProxyString; + private string _httpsProxyClientCert; + private string _httpsProxyClientKey; + private string _httpsProxyCACert; private readonly List _noProxyList = new(); private readonly HashSet _noProxyUnique = new(StringComparer.OrdinalIgnoreCase); @@ -35,6 +38,9 @@ public class RunnerWebProxy : IWebProxy public string HttpsProxyUsername => _httpsProxyUsername; public string HttpsProxyPassword => _httpsProxyPassword; public string NoProxyString => _noProxyString; + public string HttpsProxyClientCert => _httpsProxyClientCert; + public string HttpsProxyClientKey => _httpsProxyClientKey; + public string HttpsProxyCACert => _httpsProxyCACert; public List NoProxyList => _noProxyList; @@ -137,6 +143,37 @@ public RunnerWebProxy() } } + // Load mTLS client certificate configuration for proxy connections + var httpsProxyClientCert = Environment.GetEnvironmentVariable("HTTPS_PROXY_CLIENT_CERT"); + if (string.IsNullOrEmpty(httpsProxyClientCert)) + { + httpsProxyClientCert = Environment.GetEnvironmentVariable("https_proxy_client_cert"); + } + if (!string.IsNullOrEmpty(httpsProxyClientCert)) + { + _httpsProxyClientCert = httpsProxyClientCert.Trim(); + } + + var httpsProxyClientKey = Environment.GetEnvironmentVariable("HTTPS_PROXY_CLIENT_KEY"); + if (string.IsNullOrEmpty(httpsProxyClientKey)) + { + httpsProxyClientKey = Environment.GetEnvironmentVariable("https_proxy_client_key"); + } + if (!string.IsNullOrEmpty(httpsProxyClientKey)) + { + _httpsProxyClientKey = httpsProxyClientKey.Trim(); + } + + var httpsProxyCACert = Environment.GetEnvironmentVariable("HTTPS_PROXY_CA_CERT"); + if (string.IsNullOrEmpty(httpsProxyCACert)) + { + httpsProxyCACert = Environment.GetEnvironmentVariable("https_proxy_ca_cert"); + } + if (!string.IsNullOrEmpty(httpsProxyCACert)) + { + _httpsProxyCACert = httpsProxyCACert.Trim(); + } + if (!string.IsNullOrEmpty(noProxyList)) { _noProxyString = noProxyList; diff --git a/src/Test/L0/RunnerWebProxyL0.cs b/src/Test/L0/RunnerWebProxyL0.cs index 5e339e0a32f..1f9eca70799 100644 --- a/src/Test/L0/RunnerWebProxyL0.cs +++ b/src/Test/L0/RunnerWebProxyL0.cs @@ -581,6 +581,52 @@ public void WebProxyFromEnvironmentVariablesWithPort80() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void WebProxyClientCertificateFromEnvironmentVariables() + { + try + { + Environment.SetEnvironmentVariable("https_proxy", "http://127.0.0.1:9999"); + Environment.SetEnvironmentVariable("HTTPS_PROXY_CLIENT_CERT", "/path/to/client.crt"); + Environment.SetEnvironmentVariable("HTTPS_PROXY_CLIENT_KEY", "/path/to/client.key"); + Environment.SetEnvironmentVariable("HTTPS_PROXY_CA_CERT", "/path/to/ca.crt"); + var proxy = new RunnerWebProxy(); + + Assert.Equal("/path/to/client.crt", proxy.HttpsProxyClientCert); + Assert.Equal("/path/to/client.key", proxy.HttpsProxyClientKey); + Assert.Equal("/path/to/ca.crt", proxy.HttpsProxyCACert); + } + finally + { + CleanProxyEnv(); + } + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Common")] + public void WebProxyClientCertificateLowerCaseFromEnvironmentVariables() + { + try + { + Environment.SetEnvironmentVariable("https_proxy", "http://127.0.0.1:9999"); + Environment.SetEnvironmentVariable("https_proxy_client_cert", "/path/to/client.crt"); + Environment.SetEnvironmentVariable("https_proxy_client_key", "/path/to/client.key"); + Environment.SetEnvironmentVariable("https_proxy_ca_cert", "/path/to/ca.crt"); + var proxy = new RunnerWebProxy(); + + Assert.Equal("/path/to/client.crt", proxy.HttpsProxyClientCert); + Assert.Equal("/path/to/client.key", proxy.HttpsProxyClientKey); + Assert.Equal("/path/to/ca.crt", proxy.HttpsProxyCACert); + } + finally + { + CleanProxyEnv(); + } + } + private void CleanProxyEnv() { Environment.SetEnvironmentVariable("http_proxy", null); @@ -589,6 +635,12 @@ private void CleanProxyEnv() Environment.SetEnvironmentVariable("HTTPS_PROXY", null); Environment.SetEnvironmentVariable("no_proxy", null); Environment.SetEnvironmentVariable("NO_PROXY", null); + Environment.SetEnvironmentVariable("HTTPS_PROXY_CLIENT_CERT", null); + Environment.SetEnvironmentVariable("HTTPS_PROXY_CLIENT_KEY", null); + Environment.SetEnvironmentVariable("HTTPS_PROXY_CA_CERT", null); + Environment.SetEnvironmentVariable("https_proxy_client_cert", null); + Environment.SetEnvironmentVariable("https_proxy_client_key", null); + Environment.SetEnvironmentVariable("https_proxy_ca_cert", null); } } }