From 4022123a1269fb94370f07b9e893320be5a4e49d Mon Sep 17 00:00:00 2001 From: Malte Brunnlieb Date: Thu, 9 Jul 2026 10:17:02 +0200 Subject: [PATCH] #2137: fix fix-vpn-tls-problem not detecting TLS cert issues behind HTTP redirects Reuse the central redirect-following HttpClient (new HttpClientFactory) for the truststore reachability check and capture the certificate from the effective endpoint after following redirects (e.g. https://aka.ms/...), instead of a raw SSLSocket handshake against the requested host. --- CHANGELOG.adoc | 1 + .../ide/commandlet/TruststoreCommandlet.java | 22 +-- .../tools/ide/io/HttpClientFactory.java | 44 +++++ .../devonfw/tools/ide/io/HttpDownloader.java | 3 +- .../tools/ide/network/NetworkStatusImpl.java | 30 ++-- .../ide/tool/ide/IdeaPluginDownloader.java | 4 +- .../tools/ide/util/TruststoreUtil.java | 170 +++++++++++------- .../ide/commandlet/StatusCommandletTest.java | 10 +- .../commandlet/TruststoreCommandletTest.java | 34 ++++ .../tools/ide/io/HttpClientFactoryTest.java | 43 +++++ .../ide/network/NetworkStatusImplTest.java | 57 ++++++ .../tools/ide/truststore/HttpsTestServer.java | 45 +++++ .../ide/truststore/TruststoreUtilTest.java | 48 +++++ .../test/resources/truststore/localhost.p12 | Bin 0 -> 2606 bytes .../ide/url/updater/AbstractUrlUpdater.java | 4 +- 15 files changed, 423 insertions(+), 92 deletions(-) create mode 100644 cli/src/main/java/com/devonfw/tools/ide/io/HttpClientFactory.java create mode 100644 cli/src/test/java/com/devonfw/tools/ide/io/HttpClientFactoryTest.java create mode 100644 cli/src/test/java/com/devonfw/tools/ide/network/NetworkStatusImplTest.java create mode 100644 cli/src/test/java/com/devonfw/tools/ide/truststore/HttpsTestServer.java create mode 100644 cli/src/test/resources/truststore/localhost.p12 diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 8237e2c626..9943edbaa9 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -20,6 +20,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/2114[#2114]: Fix false "Cygwin is not supported" warning in Git Bash * https://github.com/devonfw/IDEasy/issues/865[#865]: az not working on Mac * https://github.com/devonfw/IDEasy/issues/2026[#2026]: Create UvRepository and UvBasedCommandlet +* https://github.com/devonfw/IDEasy/issues/2137[#2137]: Fix `fix-vpn-tls-problem` not detecting TLS certificate issues behind HTTP redirects and surface an actionable hint (failing URL + fix command) when a download fails due to a certificate trust error The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/46?closed=1[milestone 2026.07.001]. diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/TruststoreCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/TruststoreCommandlet.java index ae2d87d3f7..d7121a54e1 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/TruststoreCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/TruststoreCommandlet.java @@ -2,7 +2,6 @@ import java.nio.file.Path; import java.security.cert.X509Certificate; -import java.util.Arrays; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -87,18 +86,16 @@ protected void doRun() { throw new CliException("Invalid target URL/host '" + endpointInput + "': " + e.getMessage(), e); } - String host = endpoint.host(); - int port = endpoint.port(); Path customTruststorePath = this.context.getUserHomeIde().resolve("truststore").resolve("truststore.p12"); - if (TruststoreUtil.isTruststorePresent(customTruststorePath) && TruststoreUtil.isReachable(host, port, customTruststorePath)) { + if (TruststoreUtil.isTruststorePresent(customTruststorePath) && TruststoreUtil.isReachable(endpoint, customTruststorePath)) { IdeLogLevel.SUCCESS.log(LOG, "TLS handshake succeeded with existing custom truststore at {}.", customTruststorePath); configureIdeOptions(customTruststorePath); return; } - if (TruststoreUtil.isReachable(host, port)) { - IdeLogLevel.SUCCESS.log(LOG, "Successfully connected to {}:{} without certificate changes.", host, port); + if (TruststoreUtil.isReachable(endpoint, null)) { + IdeLogLevel.SUCCESS.log(LOG, "Successfully connected to {}:{} without certificate changes.", endpoint.host(), endpoint.port()); LOG.info("No truststore update is required for the given address."); if (defaultUrlUsed) { LOG.info( @@ -108,19 +105,22 @@ protected void doRun() { return; } - LOG.info("The given address {}:{} is not reachable/valid without certificate changes. Continuing with certificate capture.", host, port); + LOG.info("The given address {} is not reachable/valid without certificate changes. Continuing with certificate capture.", endpoint.url()); + + // download URLs are frequently redirected (e.g. https://aka.ms/...), so capture the certificate from the effective endpoint after following all redirects + TruststoreUtil.TlsEndpoint effectiveEndpoint = TruststoreUtil.resolveEffectiveEndpoint(endpoint); X509Certificate certificate; try { - certificate = TruststoreUtil.fetchServerCertificate(host, port); + certificate = TruststoreUtil.fetchServerCertificate(effectiveEndpoint.host(), effectiveEndpoint.port()); } catch (Exception e) { - LOG.error("Failed to capture certificate from {}:{}.", host, port, e); + LOG.error("Failed to capture certificate from {}:{}.", effectiveEndpoint.host(), effectiveEndpoint.port(), e); IdeLogLevel.INTERACTION.log(LOG, "Please check proxy/VPN and retry. You can also follow: https://github.com/devonfw/IDEasy/blob/main/documentation/proxy-support.adoc#tls-certificate-issues"); return; } - LOG.info("Captured untrusted certificate:"); + LOG.info("Captured untrusted certificate from {}:{}:", effectiveEndpoint.host(), effectiveEndpoint.port()); LOG.info(TruststoreUtil.describeCertificate(certificate)); boolean addToTruststore = this.context.question("Do you want to add this certificate to the custom truststore at {}?", customTruststorePath); @@ -140,7 +140,7 @@ protected void doRun() { configureIdeOptions(customTruststorePath); - if (TruststoreUtil.isReachable(host, port, customTruststorePath)) { + if (TruststoreUtil.isReachable(endpoint, customTruststorePath)) { IdeLogLevel.SUCCESS.log(LOG, "TLS handshake succeeded with custom truststore."); } else { LOG.warn("TLS handshake still fails even with custom truststore."); diff --git a/cli/src/main/java/com/devonfw/tools/ide/io/HttpClientFactory.java b/cli/src/main/java/com/devonfw/tools/ide/io/HttpClientFactory.java new file mode 100644 index 0000000000..b1e9ac05a2 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/io/HttpClientFactory.java @@ -0,0 +1,44 @@ +package com.devonfw.tools.ide.io; + +import java.net.http.HttpClient; +import java.net.http.HttpClient.Redirect; + +import javax.net.ssl.SSLContext; + +/** + * Factory for {@link HttpClient} instances that are configured consistently across IDEasy. All HTTP communication (tool and plugin downloads, reachability + * checks, URL updates, ...) has to {@link Redirect#ALWAYS follow redirects} because download URLs are frequently shortened or redirected (e.g. + * {@code https://aka.ms/...}). Centralizing the configuration here ensures that no code-path accidentally skips redirect handling. + */ +public final class HttpClientFactory { + + private HttpClientFactory() { + // utility class + } + + /** + * @return a new {@link HttpClient.Builder} that {@link Redirect#ALWAYS always follows redirects}. + */ + public static HttpClient.Builder createBuilder() { + return HttpClient.newBuilder().followRedirects(Redirect.ALWAYS); + } + + /** + * @return a new redirect-following {@link HttpClient} using the default TLS configuration. + */ + public static HttpClient create() { + return createBuilder().build(); + } + + /** + * @param sslContext the {@link SSLContext} to use for TLS connections or {@code null} to use the default configuration. + * @return a new redirect-following {@link HttpClient} using the given {@link SSLContext}. + */ + public static HttpClient create(SSLContext sslContext) { + HttpClient.Builder builder = createBuilder(); + if (sslContext != null) { + builder.sslContext(sslContext); + } + return builder.build(); + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/io/HttpDownloader.java b/cli/src/main/java/com/devonfw/tools/ide/io/HttpDownloader.java index 713c93ac73..ed1c6124a6 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/io/HttpDownloader.java +++ b/cli/src/main/java/com/devonfw/tools/ide/io/HttpDownloader.java @@ -3,7 +3,6 @@ import java.io.InputStream; import java.net.URI; import java.net.http.HttpClient; -import java.net.http.HttpClient.Redirect; import java.net.http.HttpClient.Version; import java.net.http.HttpRequest; import java.net.http.HttpRequest.BodyPublisher; @@ -42,7 +41,7 @@ public abstract class HttpDownloader { protected static HttpClient createHttpClient() { - return HttpClient.newBuilder().followRedirects(Redirect.ALWAYS).build(); + return HttpClientFactory.create(); } /** diff --git a/cli/src/main/java/com/devonfw/tools/ide/network/NetworkStatusImpl.java b/cli/src/main/java/com/devonfw/tools/ide/network/NetworkStatusImpl.java index 405d24094a..d05d26a513 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/network/NetworkStatusImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/network/NetworkStatusImpl.java @@ -116,7 +116,7 @@ public void logStatusMessage() { LOG.error(error.toString()); } if (isTlsTrustIssue(error)) { - logTruststoreFixHint(); + logTruststoreFixHint(this.onlineCheckUrl); } else { IdeLogLevel.INTERACTION.log(LOG, "Please check potential proxy settings, ensure you are properly connected to the internet and retry this operation."); } @@ -131,22 +131,30 @@ public T invokeNetworkTask(Callable callable, String uri) { configureNetworkProxy(); try { return callable.call(); - } catch (IOException e) { - this.onlineCheck.set(e); + } catch (Exception e) { + if (e instanceof IOException ioException) { + this.onlineCheck.set(ioException); + } + // the underlying SSLHandshakeException is often wrapped (e.g. as an IllegalStateException by the downloader), hence we scan the entire cause chain and + // must not rely on the exception being an IOException. if (isTlsTrustIssue(e)) { - logTruststoreFixHint(); + logTruststoreFixHint(uri); + throw new IllegalStateException("TLS certificate trust error whilst communicating to " + uri, e); } - throw new IllegalStateException("Network error whilst communicating to " + uri, e); - } catch (Exception e) { - throw new IllegalStateException("Unexpected checked exception whilst communicating to " + uri, e); + if (e instanceof IOException) { + throw new IllegalStateException("Network error whilst communicating to " + uri, e); + } + throw new IllegalStateException("Unexpected error whilst communicating to " + uri, e); } } - private void logTruststoreFixHint() { + private void logTruststoreFixHint(String uri) { - LOG.warn( - "You are having TLS trust issues (PKIX/certificate-path/SSL handshake). As a workaround you can create and configure a truststore via the following command (replace with the failing endpoint):\nide fix-vpn-tls-problem "); - IdeLogLevel.INTERACTION.log(LOG, "https://github.com/devonfw/IDEasy/blob/main/documentation/proxy-support.adoc#tls-certificate-issues"); + LOG.warn("The TLS connection to {} failed due to a certificate trust error (PKIX / certificate-path / SSL handshake). " + + "This commonly happens behind a corporate VPN or proxy that intercepts TLS traffic.", uri); + LOG.warn("Please first verify that the URL above is a legitimate endpoint that you trust and that you are reaching it securely. " + + "If it is trustworthy, you can register its certificate in a custom truststore by running:\nide fix-vpn-tls-problem {}", uri); + IdeLogLevel.INTERACTION.log(LOG, "For more details see: https://github.com/devonfw/IDEasy/blob/main/documentation/proxy-support.adoc#tls-certificate-issues"); } boolean isTlsTrustIssue(Throwable throwable) { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeaPluginDownloader.java b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeaPluginDownloader.java index 216c6a26b0..8c0956d6f1 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeaPluginDownloader.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/ide/IdeaPluginDownloader.java @@ -5,7 +5,6 @@ import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; -import java.net.http.HttpClient.Redirect; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; @@ -19,6 +18,7 @@ import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.io.FileAccess; +import com.devonfw.tools.ide.io.HttpClientFactory; import com.devonfw.tools.ide.os.MacOsHelper; import com.devonfw.tools.ide.process.ProcessContext; import com.devonfw.tools.ide.step.Step; @@ -145,7 +145,7 @@ private String getFileExtensionFromUrl(String urlString) throws RuntimeException URI uri = null; HttpRequest request; - try (HttpClient client = HttpClient.newBuilder().followRedirects(Redirect.ALWAYS).build()) { + try (HttpClient client = HttpClientFactory.create()) { uri = URI.create(urlString); request = HttpRequest.newBuilder().uri(uri) .method("HEAD", HttpRequest.BodyPublishers.noBody()).timeout(Duration.ofSeconds(5)).build(); diff --git a/cli/src/main/java/com/devonfw/tools/ide/util/TruststoreUtil.java b/cli/src/main/java/com/devonfw/tools/ide/util/TruststoreUtil.java index 61a19594ea..18fe54351e 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/util/TruststoreUtil.java +++ b/cli/src/main/java/com/devonfw/tools/ide/util/TruststoreUtil.java @@ -4,6 +4,9 @@ import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyStore; @@ -11,6 +14,7 @@ import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.time.Duration; import java.util.Arrays; import java.util.Enumeration; import java.util.Locale; @@ -24,18 +28,23 @@ import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; +import com.devonfw.tools.ide.io.HttpClientFactory; + /** * Utility methods for truststore handling and TLS certificate capture. */ public final class TruststoreUtil { /** - * Parsed TLS endpoint with host and port. + * Parsed TLS endpoint that is the single model of a requested target (or a redirect target). It encapsulates the {@link #url() HTTPS URL} together with its + * {@link #host() host} and {@link #port() port} and is used for all reachability and certificate operations. * * @param host the server host. * @param port the server port. + * @param url the normalized HTTPS URL (including the path if one was provided). Keeping the full URL is essential because redirect behavior (e.g. for + * {@code https://aka.ms/...}) depends on the path, whereas the certificate to be trusted only depends on {@link #host() host} and {@link #port() port}. */ - public record TlsEndpoint(String host, int port) { + public record TlsEndpoint(String host, int port, String url) { } @@ -226,18 +235,18 @@ public static TlsEndpoint parseTlsEndpoint(String input) { throw new IllegalArgumentException("Invalid port in input: " + input, e); } validateEndpoint(host, port, input); - return new TlsEndpoint(host, port); + return new TlsEndpoint(host, port, "https://" + host + ":" + port); } validateEndpoint(candidate, 443, input); - return new TlsEndpoint(candidate, 443); + return new TlsEndpoint(candidate, 443, "https://" + candidate); } private static TlsEndpoint parseEndpointFromUri(String input, URI uri) { String host = uri.getHost(); int port = (uri.getPort() > 0) ? uri.getPort() : 443; validateEndpoint(host, port, input); - return new TlsEndpoint(host, port); + return new TlsEndpoint(host, port, uri.toString()); } private static void validateEndpoint(String host, int port, String input) { @@ -250,46 +259,94 @@ private static void validateEndpoint(String host, int port, String input) { } /** - * Checks if a TLS endpoint can be reached and validated with the current default trust configuration. + * Checks whether the given URL can be reached with a successful TLS handshake, following HTTP redirects exactly like the actual download does (see + * {@link HttpClientFactory}). Download URLs are frequently shortened or redirected (e.g. {@code https://aka.ms/...}), and the certificate causing a TLS + * problem is usually presented by the redirect target rather than by the initially requested host. Therefore a raw socket handshake against the requested + * host is not sufficient and would falsely report success. * - * @param host the server host to connect to. - * @param port the server port to connect to. - * @return {@code true} if TLS handshake succeeds without truststore changes, {@code false} otherwise. + * @param endpoint the {@link TlsEndpoint} to probe (its {@link TlsEndpoint#url() URL} is used). + * @param truststorePath the {@link Path} to a custom truststore to use for trust validation, or {@code null} to use the default trust configuration. + * @return {@code true} if the TLS handshake succeeds across all redirects (regardless of the resulting HTTP status code), {@code false} if it fails (e.g. due + * to an untrusted certificate) or the endpoint cannot be contacted. */ - public static boolean isReachable(String host, int port) { - validateEndpoint(host, port, host + ":" + port); + public static boolean isReachable(TlsEndpoint endpoint, Path truststorePath) { try { - SSLContext sslContext = SSLContext.getInstance(TLS_PROTOCOL); - sslContext.init(null, null, new SecureRandom()); - SSLSocketFactory factory = sslContext.getSocketFactory(); - - try (SSLSocket socket = connectTlsSocket(factory, host, port)) { - socket.startHandshake(); + SSLContext sslContext = createSslContext(truststorePath); + try (HttpClient client = HttpClientFactory.create(sslContext)) { + sendProbe(client, endpoint.url()); + return true; } - return true; } catch (Exception e) { return false; } - } /** - * Checks if a TLS endpoint can be reached and validated using the provided custom truststore. + * Resolves the effective endpoint of the given endpoint by following all HTTP redirects. This is the host that actually serves the content (and hence + * presents the certificate that has to be trusted). Redirects are followed with a trust-all configuration so that the target can be determined even when an + * intermediate certificate is not (yet) trusted. * - * @param host the server host to connect to. - * @param port the server port to connect to. - * @param truststorePath the path to the custom truststore to use. - * @return {@code true} if TLS handshake succeeds with the custom truststore, {@code false} otherwise. + * @param endpoint the requested {@link TlsEndpoint} to resolve. + * @return the {@link TlsEndpoint} of the final host after following all redirects, or the given {@code endpoint} if the redirect chain cannot be resolved. */ - public static boolean isReachable(String host, int port, Path truststorePath) { - validateEndpoint(host, port, host + ":" + port); - Objects.requireNonNull(truststorePath, "truststorePath"); - try { - verifyConnectionWithTruststore(host, port, truststorePath); - return true; + public static TlsEndpoint resolveEffectiveEndpoint(TlsEndpoint endpoint) { + try (HttpClient client = HttpClientFactory.create(createTrustAllSslContext())) { + HttpResponse response = sendProbe(client, endpoint.url()); + URI effectiveUri = response.uri(); + if (effectiveUri != null) { + return parseEndpointFromUri(effectiveUri.toString(), effectiveUri); + } } catch (Exception e) { - return false; + // fall back to the requested endpoint if the redirect chain cannot be resolved } + return endpoint; + } + + /** + * Builds the probe request used for reachability checks and redirect resolution. It uses the same {@code GET} method as the actual download so that the exact + * same redirect chain (and therefore the same certificates) is exercised. A {@code Range: bytes=0-0} header keeps the transfer minimal: servers that honor it + * return a single byte, and per the HTTP specification servers that do not support ranges simply ignore the header and return the full response - so this is + * safe on any server. We only care that the TLS handshake across the redirect chain succeeds, not about the response body or status code. + */ + private static HttpRequest buildProbeRequest(String url) { + return HttpRequest.newBuilder(URI.create(url)) + .GET() + .header("Range", "bytes=0-0") + .timeout(Duration.ofMillis(DEFAULT_TIMEOUT_MILLIS)) + .build(); + } + + /** + * Sends the {@link #buildProbeRequest(String) probe request} and returns the response once its headers have been received. The body is streamed via + * {@link HttpResponse.BodyHandlers#ofInputStream()} and immediately {@link InputStream#close() closed} to abort the remaining transfer. This ensures the probe + * never downloads a large body even if a server ignored the {@code Range} header, while still completing the TLS handshake and exposing the response metadata + * (e.g. the effective {@link HttpResponse#uri() URI} after redirects). + */ + private static HttpResponse sendProbe(HttpClient client, String url) throws Exception { + HttpResponse response = client.send(buildProbeRequest(url), HttpResponse.BodyHandlers.ofInputStream()); + response.body().close(); + return response; + } + + private static SSLContext createSslContext(Path truststorePath) throws Exception { + if (truststorePath == null) { + return SSLContext.getDefault(); + } + KeyStore truststore = KeyStore.getInstance("PKCS12"); + try (InputStream in = Files.newInputStream(truststorePath)) { + truststore.load(in, CUSTOM_TRUSTSTORE_PASSWORD.toCharArray()); + } + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(truststore); + SSLContext sslContext = SSLContext.getInstance(TLS_PROTOCOL); + sslContext.init(null, trustManagerFactory.getTrustManagers(), new SecureRandom()); + return sslContext; + } + + private static SSLContext createTrustAllSslContext() throws Exception { + SSLContext sslContext = SSLContext.getInstance(TLS_PROTOCOL); + sslContext.init(null, new TrustManager[] { new TrustAllManager() }, new SecureRandom()); + return sslContext; } private static SSLSocket connectTlsSocket(SSLSocketFactory factory, String host, int port) throws Exception { @@ -347,35 +404,6 @@ public static X509Certificate fetchServerCertificate(String host, int port) thro return chain[chain.length - 1]; } - /** - * Verifies that a TLS connection to the specified host and port can be established using the truststore at the given path by performing a TLS handshake. If - * the handshake is successful, the method returns normally. If the handshake fails due to trust issues, an SSLException is thrown. - * - * @param host the server host to connect to. - * @param port the server port to connect to. - * @param truststorePath the path to the truststore file to use for the TLS handshake. - * @throws Exception if an error occurs while verifying the connection, e.g. due to connection issues, TLS handshake failure, or if the truststore file - * cannot be loaded. - */ - public static void verifyConnectionWithTruststore(String host, int port, Path truststorePath) throws Exception { - KeyStore truststore = KeyStore.getInstance("PKCS12"); - try (InputStream in = Files.newInputStream(truststorePath)) { - truststore.load(in, CUSTOM_TRUSTSTORE_PASSWORD.toCharArray()); - } - - TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - tmf.init(truststore); - - SSLContext sslContext = SSLContext.getInstance(TLS_PROTOCOL); - sslContext.init(null, tmf.getTrustManagers(), new SecureRandom()); - - SSLSocketFactory socketFactory = sslContext.getSocketFactory(); - try (SSLSocket socket = (SSLSocket) socketFactory.createSocket(host, port)) { - socket.setSoTimeout(DEFAULT_TIMEOUT_MILLIS); - socket.startHandshake(); - } - } - /** * Generates a human-readable description of the given X.509 certificate including subject, issuer, serial number, validity period, signature algorithm, and * @@ -448,4 +476,26 @@ public X509Certificate[] getChain() { return this.chain; } } + + /** + * {@link X509TrustManager} that trusts any certificate. It is only used to follow HTTP redirects in {@link #resolveEffectiveEndpoint(TlsEndpoint)} in order to + * discover the effective endpoint - it is never used to actually establish trust for downloads. + */ + private static final class TrustAllManager implements X509TrustManager { + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + // trust all - only used to follow redirects and discover the effective endpoint + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + // trust all - only used to follow redirects and discover the effective endpoint + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/StatusCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/StatusCommandletTest.java index 69ecfb01d4..f00b124267 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/StatusCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/StatusCommandletTest.java @@ -94,9 +94,11 @@ void testStatusWhenTlsIssue() throws Exception { // assert assertThat(context).log().hasEntries(IdeLogEntry.ofWarning("Skipping check for newer version of IDEasy because you are offline."), - new IdeLogEntry(IdeLogLevel.ERROR, "You are offline because of the following error:", null, null, error, false), IdeLogEntry.ofWarning( - "You are having TLS trust issues (PKIX/certificate-path/SSL handshake). As a workaround you can create and configure a truststore via the following command (replace with the failing endpoint):\nide fix-vpn-tls-problem "), - IdeLogEntry.ofInteraction("https://github.com/devonfw/IDEasy/blob/main/documentation/proxy-support.adoc#tls-certificate-issues")); + new IdeLogEntry(IdeLogLevel.ERROR, "You are offline because of the following error:", null, null, error, false)); + assertThat(context).logAtWarning().hasMessageContaining( + "The TLS connection to https://www.github.com failed due to a certificate trust error (PKIX / certificate-path / SSL handshake)."); + assertThat(context).logAtWarning().hasMessageContaining("ide fix-vpn-tls-problem https://www.github.com"); + assertThat(context).logAtInteraction().hasMessageContaining("https://github.com/devonfw/IDEasy/blob/main/documentation/proxy-support.adoc#tls-certificate-issues"); } /** @@ -115,7 +117,7 @@ void testStatusWhenPkixIssueInMessage() { status.run(); // assert - assertThat(context).logAtWarning().hasMessageContaining("ide fix-vpn-tls-problem "); + assertThat(context).logAtWarning().hasMessageContaining("ide fix-vpn-tls-problem https://www.github.com"); assertThat(context).logAtInteraction().hasMessageContaining("proxy-support.adoc#tls-certificate-issues"); } diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/TruststoreCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/TruststoreCommandletTest.java index a4ff5fdd88..9ff5972e89 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/commandlet/TruststoreCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/TruststoreCommandletTest.java @@ -1,5 +1,9 @@ package com.devonfw.tools.ide.commandlet; +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.any; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; + import java.lang.reflect.Method; import java.nio.file.Path; import java.util.Arrays; @@ -14,7 +18,9 @@ import com.devonfw.tools.ide.environment.EnvironmentVariables; import com.devonfw.tools.ide.environment.EnvironmentVariablesType; import com.devonfw.tools.ide.property.Property; +import com.devonfw.tools.ide.truststore.HttpsTestServer; import com.devonfw.tools.ide.util.TruststoreUtil; +import com.github.tomakehurst.wiremock.WireMockServer; /** * Test of {@link TruststoreCommandlet}. @@ -90,6 +96,34 @@ void testConfigureIdeOptionsReplacesExistingTruststoreOptions(@TempDir Path temp assertThat(System.getProperty("javax.net.ssl.trustStorePassword")).isEqualTo(expectedPassword); } + @Test + void testRunCapturesUntrustedCertificateFollowingRedirectAndConfiguresTruststore() { + + rememberSystemProperties(); + WireMockServer redirectingServer = HttpsTestServer.start(); + WireMockServer targetServer = HttpsTestServer.start(); + try { + String targetUrl = "https://localhost:" + targetServer.httpsPort() + "/vs_BuildTools.exe"; + targetServer.stubFor(any(urlEqualTo("/vs_BuildTools.exe")).willReturn(aResponse().withStatus(200))); + redirectingServer.stubFor(any(urlEqualTo("/vs/17/release/vs_BuildTools.exe")) + .willReturn(aResponse().withStatus(302).withHeader("Location", targetUrl))); + + IdeTestContext context = newContext(PROJECT_BASIC); + context.setAnswers("yes"); + TruststoreCommandlet commandlet = context.getCommandletManager().getCommandlet(TruststoreCommandlet.class); + setUrl(commandlet, context, "https://localhost:" + redirectingServer.httpsPort() + "/vs/17/release/vs_BuildTools.exe"); + + commandlet.run(); + + Path customTruststorePath = context.getUserHomeIde().resolve("truststore").resolve("truststore.p12"); + assertThat(customTruststorePath).exists(); + assertThat(context).logAtSuccess().hasMessageContaining("TLS handshake succeeded with custom truststore"); + } finally { + redirectingServer.stop(); + targetServer.stop(); + } + } + private IdeTestContext newIsolatedContext(Path tempDir) { IdeTestContext context = newContext(PROJECT_BASIC); diff --git a/cli/src/test/java/com/devonfw/tools/ide/io/HttpClientFactoryTest.java b/cli/src/test/java/com/devonfw/tools/ide/io/HttpClientFactoryTest.java new file mode 100644 index 0000000000..37497e4c0c --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/io/HttpClientFactoryTest.java @@ -0,0 +1,43 @@ +package com.devonfw.tools.ide.io; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.http.HttpClient; +import java.net.http.HttpClient.Redirect; + +import javax.net.ssl.SSLContext; + +import org.junit.jupiter.api.Test; + +/** + * Test of {@link HttpClientFactory}. + */ +class HttpClientFactoryTest { + + @Test + void testCreateAlwaysFollowsRedirects() { + + try (HttpClient client = HttpClientFactory.create()) { + assertThat(client.followRedirects()).isEqualTo(Redirect.ALWAYS); + } + } + + @Test + void testCreateWithSslContextAlwaysFollowsRedirects() throws Exception { + + SSLContext sslContext = SSLContext.getDefault(); + try (HttpClient client = HttpClientFactory.create(sslContext)) { + assertThat(client.followRedirects()).isEqualTo(Redirect.ALWAYS); + assertThat(client.sslContext()).isSameAs(sslContext); + } + } + + @Test + void testCreateWithNullSslContextUsesDefaultAndFollowsRedirects() { + + try (HttpClient client = HttpClientFactory.create(null)) { + assertThat(client).isNotNull(); + assertThat(client.followRedirects()).isEqualTo(Redirect.ALWAYS); + } + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/network/NetworkStatusImplTest.java b/cli/src/test/java/com/devonfw/tools/ide/network/NetworkStatusImplTest.java new file mode 100644 index 0000000000..c09298dcef --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/network/NetworkStatusImplTest.java @@ -0,0 +1,57 @@ +package com.devonfw.tools.ide.network; + +import java.util.concurrent.Callable; + +import javax.net.ssl.SSLHandshakeException; + +import org.junit.jupiter.api.Test; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; + +/** + * Test of {@link NetworkStatusImpl}. + */ +class NetworkStatusImplTest extends AbstractIdeContextTest { + + /** + * Verifies that a TLS certificate trust error is detected and reported with an actionable hint even when the {@link SSLHandshakeException} is wrapped in a + * {@link RuntimeException} - which is how the downloader propagates it and which previously slipped through as an "unexpected" error without any hint. + */ + @Test + void testInvokeNetworkTaskDetectsWrappedTlsTrustIssueAndReportsFailingUrl() { + + IdeTestContext context = newContext(PROJECT_BASIC); + NetworkStatusImpl networkStatus = new NetworkStatusImpl(context); + String uri = "https://aka.ms/vs/17/release/vs_BuildTools.exe"; + Callable failingTask = () -> { + SSLHandshakeException tlsError = new SSLHandshakeException( + "PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target"); + // mimics how HttpDownloader wraps the handshake error in a RuntimeException + throw new IllegalStateException("Failed to stream response body from url: " + uri, tlsError); + }; + + assertThatThrownBy(() -> networkStatus.invokeNetworkTask(failingTask, uri)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("TLS certificate trust error") + .hasMessageContaining(uri); + + assertThat(context).logAtWarning().hasMessageContaining("ide fix-vpn-tls-problem " + uri); + } + + /** + * Verifies that {@link NetworkStatusImpl#isTlsTrustIssue(Throwable)} detects the PKIX indicator anywhere in the cause chain. + */ + @Test + void testIsTlsTrustIssueDetectsPkixInCauseChain() { + + IdeTestContext context = newContext(PROJECT_BASIC); + NetworkStatusImpl networkStatus = new NetworkStatusImpl(context); + + Throwable wrapped = new IllegalStateException("outer", + new IllegalStateException("middle", new SSLHandshakeException("PKIX path building failed: unable to find valid certification path"))); + + assertThat(networkStatus.isTlsTrustIssue(wrapped)).isTrue(); + assertThat(networkStatus.isTlsTrustIssue(new IllegalStateException("some unrelated network glitch"))).isFalse(); + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/truststore/HttpsTestServer.java b/cli/src/test/java/com/devonfw/tools/ide/truststore/HttpsTestServer.java new file mode 100644 index 0000000000..4a74cb31f5 --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/truststore/HttpsTestServer.java @@ -0,0 +1,45 @@ +package com.devonfw.tools.ide.truststore; + +import java.nio.file.Path; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; + +/** + * Test helper to start a WireMock HTTPS server that presents a self-signed {@code CN=localhost} certificate (with matching {@code localhost} SAN so that + * hostname verification of the JDK {@link java.net.http.HttpClient} passes). The certificate is not part of the JRE {@code cacerts} and therefore simulates an + * untrusted (e.g. VPN-intercepted) endpoint. + */ +public final class HttpsTestServer { + + private static final String KEYSTORE_RESOURCE = "/truststore/localhost.p12"; + + private static final String KEYSTORE_PASSWORD = "changeit"; + + private HttpsTestServer() { + // utility class + } + + /** + * @return a freshly started {@link WireMockServer} listening on a dynamic HTTPS port. The caller is responsible for {@link WireMockServer#stop() stopping} it. + */ + public static WireMockServer start() { + WireMockServer server = new WireMockServer(WireMockConfiguration.options() + .httpDisabled(true) + .dynamicHttpsPort() + .keystorePath(keystorePath()) + .keystorePassword(KEYSTORE_PASSWORD) + .keyManagerPassword(KEYSTORE_PASSWORD) + .keystoreType("PKCS12")); + server.start(); + return server; + } + + private static String keystorePath() { + try { + return Path.of(HttpsTestServer.class.getResource(KEYSTORE_RESOURCE).toURI()).toAbsolutePath().toString(); + } catch (Exception e) { + throw new IllegalStateException("Failed to resolve test keystore resource: " + KEYSTORE_RESOURCE, e); + } + } +} diff --git a/cli/src/test/java/com/devonfw/tools/ide/truststore/TruststoreUtilTest.java b/cli/src/test/java/com/devonfw/tools/ide/truststore/TruststoreUtilTest.java index 79a75c4da9..e607acf071 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/truststore/TruststoreUtilTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/truststore/TruststoreUtilTest.java @@ -1,5 +1,8 @@ package com.devonfw.tools.ide.truststore; +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.any; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -17,6 +20,7 @@ import org.junit.jupiter.api.io.TempDir; import com.devonfw.tools.ide.util.TruststoreUtil; +import com.github.tomakehurst.wiremock.WireMockServer; /** * Test of {@link TruststoreUtil}. @@ -135,6 +139,50 @@ void testFetchServerCertificateValidatesInput() { .hasMessageContaining("port must be between 1 and 65535"); } + @Test + void testIsReachableFalseForUntrustedCertificateAndTrueWithCapturedCertificate() throws Exception { + + WireMockServer server = HttpsTestServer.start(); + try { + server.stubFor(any(urlEqualTo("/")).willReturn(aResponse().withStatus(200))); + TruststoreUtil.TlsEndpoint endpoint = TruststoreUtil.parseTlsEndpoint("https://localhost:" + server.httpsPort() + "/"); + + // the self-signed localhost certificate is not part of the JRE cacerts, so the default trust configuration must reject it + assertThat(TruststoreUtil.isReachable(endpoint, null)).isFalse(); + + X509Certificate certificate = TruststoreUtil.fetchServerCertificate(endpoint.host(), endpoint.port()); + Path truststorePath = this.tempDir.resolve("captured.p12"); + TruststoreUtil.createOrUpdateTruststore(truststorePath, certificate, "custom"); + + // once the captured certificate is part of the custom truststore the endpoint becomes reachable + assertThat(TruststoreUtil.isReachable(endpoint, truststorePath)).isTrue(); + } finally { + server.stop(); + } + } + + @Test + void testResolveEffectiveEndpointFollowsRedirectToFinalHost() { + + WireMockServer redirectingServer = HttpsTestServer.start(); + WireMockServer targetServer = HttpsTestServer.start(); + try { + String targetUrl = "https://localhost:" + targetServer.httpsPort() + "/target"; + targetServer.stubFor(any(urlEqualTo("/target")).willReturn(aResponse().withStatus(200))); + redirectingServer.stubFor(any(urlEqualTo("/redirect")) + .willReturn(aResponse().withStatus(302).withHeader("Location", targetUrl))); + + TruststoreUtil.TlsEndpoint requested = TruststoreUtil.parseTlsEndpoint("https://localhost:" + redirectingServer.httpsPort() + "/redirect"); + TruststoreUtil.TlsEndpoint effective = TruststoreUtil.resolveEffectiveEndpoint(requested); + + assertThat(effective.host()).isEqualTo("localhost"); + assertThat(effective.port()).isEqualTo(targetServer.httpsPort()); + } finally { + redirectingServer.stop(); + targetServer.stop(); + } + } + @Test void testLoadCertificateFromResource() throws Exception { diff --git a/cli/src/test/resources/truststore/localhost.p12 b/cli/src/test/resources/truststore/localhost.p12 new file mode 100644 index 0000000000000000000000000000000000000000..6352ddcc5a32025ea457fab01a3f850bd0a526a1 GIT binary patch literal 2606 zcma)8X*d*&7M>Y1G=pq|WQfU<$uc9$OeB#dLPWAm5k5=yo$zUFjclJ?_GB!P>|`5; zEKRmTGKBj0GO~>fA=iEGefsYGb?=Y!yyv{{Ip^PbpMxgAP#_=+ngD&z3Pr{0$1*vA zY``J{v=2;xb|2FnXac0+za&U0m;fn0rt^;@hn4-`QyefLsE7d3JSNrAzx|qkaH6fy z*uP5-^m#CR?I)L@=eK;D62p^K={Ee z0!@)#zEXsR=;u`AiYiGebf9+N&9=m(el@$GYg${zacFC!J!(UX5qZ*RRW}O2@s!6{ zYANed|Hap@`2uhoRl#7_5*dhC30Myd17kHv7mT5%Fw3Q39!0(_uKmmIs5q|Phr`K( z*_Ka!eA*!fMd-X*;Rw~)?eOf} zQEv@VpD9p{{xRj2GWkj3kR=YIAq-&agE=3tk5}RpyHaw*#SKp*!~_v&HTSABSfy5g zzT&cV-IDgu!>iI-v~vN2i{aA0c~Xt7{N%PX?_PA_$=Kl$BO`Ym%obL%Xe$zG30jP( zpKpYNq_Wc(0-MIZ#(qxYP0h4vyBUq`&byS*utUerA)`OS7Er8DL@;CvD^={Ryf!y= zHXjPdwBDb7>?zWYu(j4P3&#zHKw8eVa`x6XkySB>MX^TR3v*>BRUPRsQfaYV68hpN zr2}oZn-3^#;V%oC4YqV8z{VMsYSYOLYELsqe(>i_?q!F{6#J zk)|&pmUtwq$+Bib=!ohWYv#;{+tl~Hl$ZVkd!#_DL0A4-N|!{o(ixK-9rk%TmwguHi6!I5KC;)*_sl1%w|JIi&o z&TbZB^kb*)W`2Z>7t7t?sMWttOxrOwkq^?Xxad8q_nTDe?zwsV78+6zce?yh8L|La zQtGA0Cr)n%s!@ZREPmwR+#ilSk10g92odyebe$WzgtS0KA;C+c7DbTg45nqWAM|HWotlja;#TBseD`-qLHT_mR9A1x9iT!?ov1Qvd> zDWg3;UrxMv9Rs`7sDNU_XVmQTj>(@xEYAy{!dv5NSgDgVX)1!*ePWicB(v-=KrNti z!gsPPRj8x1E3hpkLr~CWYqy7<9%Mh*Zj#3Q)6mhC=cHIb zo@3YLPTQ&ug-&PA}vxndF)zJt-6n`&=dLg^ZFE|Jux$w^~00lyp^ z@Pw^X^DQ}vz>597%O9FKr@%i~S}2b)fC#)ce~Cpagp}Vm-hoKNy}YR-VbG)3llyvc z+w=z_>^nw>$`O{lK`U_)B^^auEo8X&&NwG))cSo`@sLXOtbD4}DZrng0fXlg`BA|( z_};KTizmBYK_A85(H12PP)V)@UK7iUk^7DvMVIk4s z|1oPlpJeef5bKCQc^!WZVDQUG0*iN4RdD)cjTpL&1vb2B{4)u$9x6Wx2b`}y1JdPSG=zHJ4#**9MAx~N%T zM&;SCk;ci#CqAb->%5hWk4+mED2-jSPF8^hDz9|N8hzc@! zY@@5NPhie&48jQ%`vMajR z^|#=UKcAj^WC$W}gno9_*B?yEO*-c=?1wjJiMM;8Yrg0`A(|QS=7*|I-sLY>Hn6Pm zgF+-kw|Ev5UKp>W8plr@$W~ZUBz!Nd~2j0aUysk@?`UY6dfsRWiQX2Xb(&a zi6=)IMiy#)sV%|;n<5&Sp2Jf=VWjHwm14RT||Fs|sFa%F&0rDWxh5uO90qJ=->5;>`v*+xjGjDI{F(%XRa8gJHM%zk6Z zr(x09t8I~(vH{lz;%B7m)7|FKMraJ0^Vbgr0zv>_F}$Ga^Qu}ot)`d-8{C^UO#|7x qw`DSA>rtc9i05?MLNMHIoVrF-GZh`5SBLB|J@0T%Glc$m^8WyT39LE* literal 0 HcmV?d00001 diff --git a/url-updater/src/main/java/com/devonfw/tools/ide/url/updater/AbstractUrlUpdater.java b/url-updater/src/main/java/com/devonfw/tools/ide/url/updater/AbstractUrlUpdater.java index 360c38af88..f59146e99c 100644 --- a/url-updater/src/main/java/com/devonfw/tools/ide/url/updater/AbstractUrlUpdater.java +++ b/url-updater/src/main/java/com/devonfw/tools/ide/url/updater/AbstractUrlUpdater.java @@ -4,7 +4,6 @@ import java.io.InputStream; import java.net.URI; import java.net.http.HttpClient; -import java.net.http.HttpClient.Redirect; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.file.Path; @@ -23,6 +22,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.devonfw.tools.ide.io.HttpClientFactory; import com.devonfw.tools.ide.io.HttpErrorResponse; import com.devonfw.tools.ide.os.OperatingSystem; import com.devonfw.tools.ide.os.SystemArchitecture; @@ -76,7 +76,7 @@ public abstract class AbstractUrlUpdater extends AbstractProcessorWithTimeout im private static final Set URL_FILENAMES_OS_INDEPENDENT = Set.of("urls"); /** The {@link HttpClient} for HTTP requests. */ - protected final HttpClient client = HttpClient.newBuilder().followRedirects(Redirect.ALWAYS).build(); + protected final HttpClient client = HttpClientFactory.create(); /** The GitHub actions token name for api requests. */ private static final String GITHUB_API_TOKEN_ENV = "GHA_TOKEN";