diff --git a/.gitignore b/.gitignore index a7a57dc42bd..104a91c704c 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,4 @@ TestLogs .DS_Store .mono **/*.DotSettings.user -**/*.lscache \ No newline at end of file +**/*.lscache 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/Runner.Sdk/Util/VssUtil.cs b/src/Runner.Sdk/Util/VssUtil.cs index 012d27f7345..84a3f5e3683 100644 --- a/src/Runner.Sdk/Util/VssUtil.cs +++ b/src/Runner.Sdk/Util/VssUtil.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; using System.Net.Http; +using System.Security.Cryptography.X509Certificates; using GitHub.DistributedTask.WebApi; using GitHub.Services.Common; using GitHub.Services.WebApi; @@ -34,6 +36,38 @@ public static void InitializeVssClientSettings(List addi VssClientHttpRequestSettings.Default.UserAgent = headerValues; VssHttpMessageHandler.DefaultWebProxy = proxy; + RawHttpMessageHandler.DefaultWebProxy = proxy; + + // Wire mTLS client cert loading into both HTTP message handlers + var certPath = Environment.GetEnvironmentVariable("HTTPS_PROXY_CLIENT_CERT") + ?? Environment.GetEnvironmentVariable("https_proxy_client_cert"); + var keyPath = Environment.GetEnvironmentVariable("HTTPS_PROXY_CLIENT_KEY") + ?? Environment.GetEnvironmentVariable("https_proxy_client_key"); + if (!string.IsNullOrEmpty(certPath) && File.Exists(certPath)) + { + Action configureCerts = (HttpClientHandler handler) => + { + try + { + var certPem = File.ReadAllText(certPath); + var keyPem = !string.IsNullOrEmpty(keyPath) && File.Exists(keyPath) ? File.ReadAllText(keyPath) : null; + X509Certificate2 cert; + if (keyPem != null) + { + cert = X509Certificate2.CreateFromPem(certPem, keyPem); + cert = new X509Certificate2(cert.Export(X509ContentType.Pfx)); + } + else + { + cert = new X509Certificate2(certPath); + } + handler.ClientCertificates.Add(cert); + } + catch { } + }; + VssHttpMessageHandler.ConfigureClientCertificates = configureCerts; + RawHttpMessageHandler.ConfigureClientCertificates = configureCerts; + } if (StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("GITHUB_ACTIONS_RUNNER_TLS_NO_VERIFY"))) { diff --git a/src/Sdk/Common/Common/RawHttpMessageHandler.cs b/src/Sdk/Common/Common/RawHttpMessageHandler.cs index e80e6a74727..1bef8ee7f6f 100644 --- a/src/Sdk/Common/Common/RawHttpMessageHandler.cs +++ b/src/Sdk/Common/Common/RawHttpMessageHandler.cs @@ -77,6 +77,8 @@ public RawClientHttpRequestSettings Settings // This needs to be investigated further. private static IWebProxy s_defaultWebProxy = null; + public static Action ConfigureClientCertificates { get; set; } + /// /// Allows you to set a proxy to be used by all RawHttpMessageHandler requests without affecting the global WebRequest.DefaultWebProxy. If not set it returns the WebRequest.DefaultWebProxy. /// @@ -300,6 +302,8 @@ private static void ApplySettings( httpClientHandler.Proxy = DefaultWebProxy; httpClientHandler.UseCookies = false; httpClientHandler.UseProxy = true; + + ConfigureClientCertificates?.Invoke(httpClientHandler); } } diff --git a/src/Sdk/Common/Common/VssHttpMessageHandler.cs b/src/Sdk/Common/Common/VssHttpMessageHandler.cs index f48eec41af8..46178dfa029 100644 --- a/src/Sdk/Common/Common/VssHttpMessageHandler.cs +++ b/src/Sdk/Common/Common/VssHttpMessageHandler.cs @@ -503,9 +503,13 @@ private static void ApplySettings( { httpClientHandler.AutomaticDecompression = DecompressionMethods.GZip; } + + ConfigureClientCertificates?.Invoke(httpClientHandler); } } + public static Action ConfigureClientCertificates { get; set; } + // setting this to WebRequest.DefaultWebProxy in NETSTANDARD is causing a System.PlatformNotSupportedException //.in System.Net.SystemWebProxy.IsBypassed. Comment in IsBypassed method indicates ".NET Core and .NET Native // code will handle this exception and call into WinInet/WinHttp as appropriate to use the system proxy." diff --git a/src/Sdk/WebApi/WebApi/OAuth/VssOAuthTokenHttpClient.cs b/src/Sdk/WebApi/WebApi/OAuth/VssOAuthTokenHttpClient.cs index 994675e41e0..d0b61bb93df 100644 --- a/src/Sdk/WebApi/WebApi/OAuth/VssOAuthTokenHttpClient.cs +++ b/src/Sdk/WebApi/WebApi/OAuth/VssOAuthTokenHttpClient.cs @@ -124,6 +124,8 @@ private static HttpMessageHandler CreateMessageHandler(Uri requestUri) messageHandler.UseProxy = true; } + VssHttpMessageHandler.ConfigureClientCertificates?.Invoke(messageHandler); + if (requestUri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase) && VssClientHttpRequestSettings.Default.ClientCertificateManager != null && VssClientHttpRequestSettings.Default.ClientCertificateManager.ClientCertificates != null && diff --git a/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs b/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs index 31819a4b2bf..5f692be244e 100644 --- a/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs +++ b/src/Sdk/WebApi/WebApi/ResultsHttpClient.cs @@ -5,10 +5,12 @@ using System.Linq; using System.Net.Http; using System.Net.Http.Headers; +using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; using System.Net.Http.Formatting; using Azure; +using Azure.Core.Pipeline; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; using Azure.Storage.Blobs.Specialized; @@ -191,6 +193,42 @@ private async Task JobLogUploadCompleteAsync(string planId, string jobId, long l return (blobUri.Uri, sasUrl); } + private HttpClientTransport GetMTLSTransport() + { + var certPath = Environment.GetEnvironmentVariable("HTTPS_PROXY_CLIENT_CERT") + ?? Environment.GetEnvironmentVariable("https_proxy_client_cert"); + var keyPath = Environment.GetEnvironmentVariable("HTTPS_PROXY_CLIENT_KEY") + ?? Environment.GetEnvironmentVariable("https_proxy_client_key"); + + if (string.IsNullOrEmpty(certPath) || !File.Exists(certPath)) + { + return null; + } + + try + { + var handler = new HttpClientHandler(); + var certPem = File.ReadAllText(certPath); + var keyPem = !string.IsNullOrEmpty(keyPath) && File.Exists(keyPath) ? File.ReadAllText(keyPath) : null; + X509Certificate2 cert; + if (keyPem != null) + { + cert = X509Certificate2.CreateFromPem(certPem, keyPem); + cert = new X509Certificate2(cert.Export(X509ContentType.Pfx)); + } + else + { + cert = new X509Certificate2(certPath); + } + handler.ClientCertificates.Add(cert); + return new HttpClientTransport(handler); + } + catch + { + return null; + } + } + private BlobClient GetBlobClient(string url) { var blobUri = ParseSasToken(url); @@ -204,6 +242,12 @@ private BlobClient GetBlobClient(string url) } }; + var transport = GetMTLSTransport(); + if (transport != null) + { + opts.Transport = transport; + } + return new BlobClient(blobUri.path, new AzureSasCredential(blobUri.sas), opts); } @@ -220,6 +264,12 @@ private AppendBlobClient GetAppendBlobClient(string url) } }; + var transport = GetMTLSTransport(); + if (transport != null) + { + opts.Transport = transport; + } + return new AppendBlobClient(blobUri.path, new AzureSasCredential(blobUri.sas), opts); } 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); } } }