View Javadoc
1   /*
2    * This file is part of dependency-check-utils.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13   * See the License for the specific language governing permissions and
14   * limitations under the License.
15   *
16   * Copyright (c) 2024 Hans Aikema. All Rights Reserved.
17   */
18  package org.owasp.dependencycheck.utils;
19  
20  import org.apache.hc.client5.http.HttpResponseException;
21  import org.apache.hc.client5.http.auth.AuthCache;
22  import org.apache.hc.client5.http.auth.AuthScope;
23  import org.apache.hc.client5.http.auth.Credentials;
24  import org.apache.hc.client5.http.auth.CredentialsStore;
25  import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
26  import org.apache.hc.client5.http.config.ConnectionConfig;
27  import org.apache.hc.client5.http.impl.auth.BasicAuthCache;
28  import org.apache.hc.client5.http.impl.auth.BasicScheme;
29  import org.apache.hc.client5.http.impl.auth.SystemDefaultCredentialsProvider;
30  import org.apache.hc.client5.http.impl.classic.BasicHttpClientResponseHandler;
31  import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
32  import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
33  import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
34  import org.apache.hc.client5.http.protocol.HttpClientContext;
35  import org.apache.hc.core5.http.ClassicHttpResponse;
36  import org.apache.hc.core5.http.ContentType;
37  import org.apache.hc.core5.http.Header;
38  import org.apache.hc.core5.http.HttpEntity;
39  import org.apache.hc.core5.http.HttpException;
40  import org.apache.hc.core5.http.HttpHeaders;
41  import org.apache.hc.core5.http.HttpHost;
42  import org.apache.hc.core5.http.Method;
43  import org.apache.hc.core5.http.io.HttpClientResponseHandler;
44  import org.apache.hc.core5.http.io.entity.BasicHttpEntity;
45  import org.apache.hc.core5.http.io.entity.StringEntity;
46  import org.apache.hc.core5.http.message.BasicClassicHttpRequest;
47  import org.apache.hc.core5.http.message.BasicClassicHttpResponse;
48  import org.jetbrains.annotations.NotNull;
49  import org.slf4j.Logger;
50  import org.slf4j.LoggerFactory;
51  
52  import javax.net.ssl.SSLHandshakeException;
53  import java.io.File;
54  import java.io.IOException;
55  import java.io.InputStream;
56  import java.net.InetSocketAddress;
57  import java.net.MalformedURLException;
58  import java.net.Proxy;
59  import java.net.ProxySelector;
60  import java.net.SocketAddress;
61  import java.net.URI;
62  import java.net.URISyntaxException;
63  import java.net.URL;
64  import java.nio.charset.Charset;
65  import java.nio.file.Files;
66  import java.nio.file.Path;
67  import java.nio.file.Paths;
68  import java.nio.file.StandardCopyOption;
69  import java.util.ArrayList;
70  import java.util.Collections;
71  import java.util.List;
72  import java.util.Locale;
73  import java.util.concurrent.TimeUnit;
74  
75  import static java.lang.String.format;
76  
77  /**
78   * A Utility class to centralize download logic like HTTP(S) proxy configuration and proxy- and server credential handling.
79   * @author Jeremy Long, Hans Aikema
80   */
81  public final class Downloader {
82  
83      /**
84       * The builder to use for a HTTP Client that uses the configured proxy-settings
85       */
86      private final HttpClientBuilder httpClientBuilder;
87  
88      /**
89       * The builder to use for a HTTP Client that explicitly opts out of proxy-usage
90       */
91      private final HttpClientBuilder httpClientBuilderExplicitNoproxy;
92  
93      /**
94       * The connectionmanager for HTTP connection pooling shared by the client builders.
95       */
96      private final PoolingHttpClientConnectionManager connectionManager;
97  
98      /**
99       * The Authentication cache for pre-emptive authentication.
100      * This gets filled with credentials from the settings in {@link #configure(Settings)}.
101      */
102     private final AuthCache authCache = new BasicAuthCache();
103 
104     /**
105      * The credentialsProvider for pre-emptive authentication.
106      * This gets filled with credentials from the settings in {@link #configure(Settings)}.
107      */
108     private final SystemDefaultCredentialsProvider credentialsProvider = new SystemDefaultCredentialsProvider();
109 
110     /**
111      * The settings
112      */
113     private Settings settings;
114 
115     /**
116      * The Logger for use throughout the class.
117      */
118     private static final Logger LOGGER = LoggerFactory.getLogger(Downloader.class);
119 
120     /**
121      * The singleton instance of the downloader
122      */
123     private static final Downloader INSTANCE = new Downloader();
124     /**
125      * The Credentials for the proxy when proxy authentication is configured in the Settings.
126      */
127     private Credentials proxyCreds = null;
128     /**
129      * A BasicScheme initialized with the proxy-credentials when proxy authentication is configured in the Settings.
130      */
131     private BasicScheme proxyPreEmptAuth = null;
132     /**
133      * The AuthScope for the proxy when proxy authentication is configured in the Settings.
134      */
135     private AuthScope proxyAuthScope = null;
136     /**
137      * The HttpHost for the proxy when proxy authentication is configured in the Settings.
138      */
139     private HttpHost proxyHttpHost = null;
140 
141     private Downloader() {
142         // Singleton class
143         connectionManager = new PoolingHttpClientConnectionManager();
144         //TODO: ensure proper closure and eviction policy
145         httpClientBuilder = HttpClientBuilder.create()
146                 .useSystemProperties()
147                 .setConnectionManager(connectionManager)
148                 .setConnectionManagerShared(true);
149         httpClientBuilderExplicitNoproxy = HttpClientBuilder.create()
150                 .useSystemProperties()
151                 .setConnectionManager(connectionManager)
152                 .setConnectionManagerShared(true)
153                 .setProxySelector(new ProxySelector() {
154                     @Override
155                     public List<Proxy> select(URI uri) {
156                         return Collections.singletonList(Proxy.NO_PROXY);
157                     }
158 
159                     @Override
160                     public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
161 
162                     }
163                 });
164     }
165 
166     /**
167      * The singleton instance for downloading file resources.
168      *
169      * @return The singleton instance managing download credentials and proxy configuration
170      */
171     public static Downloader getInstance() {
172         return INSTANCE;
173     }
174 
175     /**
176      * Initialize the Downloader from the settings.
177      * Extracts the configured proxy- and credential information from the settings and system properties and
178      * caches those for future use by the Downloader.
179      *
180      * @param settings The settings to configure from
181      * @throws InvalidSettingException When improper configurations are found.
182      */
183     public void configure(Settings settings) throws InvalidSettingException {
184         this.settings = settings;
185         final long connectionTimeout = settings.getLong(Settings.KEYS.CONNECTION_TIMEOUT, 10_000);
186         // set a conservatively long default timeout to compensate for MITM-proxies that return the (final) bytes only
187         // after all security checks passed
188         final int readTimeout = settings.getInt(Settings.KEYS.CONNECTION_READ_TIMEOUT, 60_000);
189         connectionManager.setDefaultConnectionConfig(
190                 ConnectionConfig.custom()
191                         .setConnectTimeout(connectionTimeout, TimeUnit.MILLISECONDS)
192                         .setSocketTimeout(readTimeout, TimeUnit.MILLISECONDS)
193                         .build()
194         );
195         if (!settings.getString(Settings.KEYS.PROXY_SERVER, "").isBlank()) {
196             // Legacy proxy configuration present
197             // So don't rely on the system properties for proxy; use the legacy settings configuration
198             final String proxyHost = settings.getString(Settings.KEYS.PROXY_SERVER);
199             final int proxyPort = settings.getInt(Settings.KEYS.PROXY_PORT, -1);
200             final String nonProxyHosts = settings.getString(Settings.KEYS.PROXY_NON_PROXY_HOSTS, "");
201             if (!nonProxyHosts.isBlank()) {
202                 final ProxySelector selector = new SelectiveProxySelector(
203                         new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)),
204                         nonProxyHosts.split("\\|")
205                 );
206                 httpClientBuilder.setProxySelector(selector);
207             } else {
208                 httpClientBuilder.setProxy(new HttpHost(proxyHost, proxyPort));
209             }
210             if (!settings.getString(Settings.KEYS.PROXY_USERNAME, "").isBlank() && !settings.getString(Settings.KEYS.PROXY_PASSWORD, "").isBlank()) {
211                 final String proxyuser = settings.getString(Settings.KEYS.PROXY_USERNAME);
212                 final char[] proxypass = settings.getString(Settings.KEYS.PROXY_PASSWORD).toCharArray();
213                 this.proxyHttpHost = new HttpHost(null, proxyHost, proxyPort);
214                 this.proxyCreds = new UsernamePasswordCredentials(proxyuser, proxypass);
215                 this.proxyAuthScope = new AuthScope(proxyHttpHost);
216                 this.proxyPreEmptAuth = new BasicScheme();
217                 this.proxyPreEmptAuth.initPreemptive(proxyCreds);
218                 tryConfigureProxyCredentials(credentialsProvider, authCache);
219             }
220         }
221         tryAddRetireJSCredentials();
222         tryAddHostedSuppressionCredentials();
223         tryAddKEVCredentials();
224         tryAddNexusAnalyzerCredentials();
225         tryAddArtifactoryCredentials();
226         tryAddCentralAnalyzerCredentials();
227         tryAddCentralContentCredentials();
228         tryAddNVDApiDatafeed();
229         httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider);
230         httpClientBuilderExplicitNoproxy.setDefaultCredentialsProvider(credentialsProvider);
231     }
232 
233     private void tryAddRetireJSCredentials() throws InvalidSettingException {
234         if (!settings.getString(Settings.KEYS.ANALYZER_RETIREJS_REPO_JS_URL, "").isBlank()) {
235             configureCredentials(Settings.KEYS.ANALYZER_RETIREJS_REPO_JS_URL, "RetireJS repo.js",
236                     Settings.KEYS.ANALYZER_RETIREJS_REPO_JS_USER, Settings.KEYS.ANALYZER_RETIREJS_REPO_JS_PASSWORD,
237                     Settings.KEYS.ANALYZER_RETIREJS_REPO_JS_BEARER_TOKEN
238                     );
239         }
240     }
241 
242     private void tryAddHostedSuppressionCredentials() throws InvalidSettingException {
243         if (!settings.getString(Settings.KEYS.HOSTED_SUPPRESSIONS_URL, "").isBlank()) {
244             configureCredentials(Settings.KEYS.HOSTED_SUPPRESSIONS_URL, "Hosted suppressions",
245                     Settings.KEYS.HOSTED_SUPPRESSIONS_USER, Settings.KEYS.HOSTED_SUPPRESSIONS_PASSWORD,
246                     Settings.KEYS.HOSTED_SUPPRESSIONS_BEARER_TOKEN
247             );
248         }
249     }
250 
251     private void tryAddKEVCredentials() throws InvalidSettingException {
252         if (!settings.getString(Settings.KEYS.KEV_URL, "").isBlank()) {
253             configureCredentials(Settings.KEYS.KEV_URL, "Known Exploited Vulnerabilities",
254                     Settings.KEYS.KEV_USER, Settings.KEYS.KEV_PASSWORD,
255                     Settings.KEYS.KEV_BEARER_TOKEN
256             );
257         }
258     }
259 
260     private void tryAddNexusAnalyzerCredentials() throws InvalidSettingException {
261         if (!settings.getString(Settings.KEYS.ANALYZER_NEXUS_URL, "").isBlank()) {
262             configureCredentials(Settings.KEYS.ANALYZER_NEXUS_URL, "Nexus Analyzer",
263                     Settings.KEYS.ANALYZER_NEXUS_USER, Settings.KEYS.ANALYZER_NEXUS_PASSWORD,
264                     null
265             );
266         }
267     }
268 
269     private void tryAddCentralAnalyzerCredentials() throws InvalidSettingException {
270         if (!settings.getString(Settings.KEYS.ANALYZER_CENTRAL_URL, "").isBlank()) {
271             configureCredentials(Settings.KEYS.ANALYZER_CENTRAL_URL, "Central Analyzer",
272                     Settings.KEYS.ANALYZER_CENTRAL_USER, Settings.KEYS.ANALYZER_CENTRAL_PASSWORD,
273                     Settings.KEYS.ANALYZER_CENTRAL_BEARER_TOKEN
274             );
275         }
276     }
277 
278     private void tryAddArtifactoryCredentials() throws InvalidSettingException {
279         if (!settings.getString(Settings.KEYS.ANALYZER_ARTIFACTORY_URL, "").isBlank()) {
280             configureCredentials(Settings.KEYS.ANALYZER_ARTIFACTORY_URL, "Artifactory Analyzer",
281                     Settings.KEYS.ANALYZER_ARTIFACTORY_API_USERNAME, Settings.KEYS.ANALYZER_ARTIFACTORY_API_TOKEN,
282                     Settings.KEYS.ANALYZER_ARTIFACTORY_BEARER_TOKEN
283             );
284         }
285     }
286 
287     private void tryAddCentralContentCredentials() throws InvalidSettingException {
288         if (!settings.getString(Settings.KEYS.CENTRAL_CONTENT_URL, "").isBlank()) {
289             configureCredentials(Settings.KEYS.CENTRAL_CONTENT_URL, "Central Content",
290                     Settings.KEYS.CENTRAL_CONTENT_USER, Settings.KEYS.CENTRAL_CONTENT_PASSWORD,
291                     Settings.KEYS.CENTRAL_CONTENT_BEARER_TOKEN
292 
293             );
294         }
295     }
296 
297     private void tryAddNVDApiDatafeed() throws InvalidSettingException {
298         if (!settings.getString(Settings.KEYS.NVD_API_DATAFEED_URL, "").isBlank()) {
299             configureCredentials(Settings.KEYS.NVD_API_DATAFEED_URL, "NVD API Datafeed",
300                     Settings.KEYS.NVD_API_DATAFEED_USER, Settings.KEYS.NVD_API_DATAFEED_PASSWORD,
301                     Settings.KEYS.NVD_API_DATAFEED_BEARER_TOKEN
302             );
303         }
304     }
305 
306     /**
307      * Configure pre-emptive credentials for the host/port of the URL when configured in settings for the default credential-store and
308      * authentication-cache.
309      *
310      * @param urlKey           The settings property key for a configured url for which the credentials should hold
311      * @param scopeDescription A descriptive text for use in error messages for this credential
312      * @param userKey          The settings property key for a potentially configured configured Basic-auth username
313      * @param passwordKey      The settings property key for a potentially configured configured Basic-auth password
314      * @param tokenKey         The settings property key for a potentially configured Bearer-auth token
315      * @throws InvalidSettingException When the password is empty or one of the other keys are not found in the settings.
316      */
317     private void configureCredentials(String urlKey, String scopeDescription, String userKey, String passwordKey, String tokenKey)
318             throws InvalidSettingException {
319         final URL theURL;
320         try {
321             theURL = new URL(settings.getString(urlKey, ""));
322         } catch (MalformedURLException e) {
323             throw new InvalidSettingException(scopeDescription + " URL must be a valid URL (was: " + settings.getString(urlKey, "") + ")", e);
324         }
325         configureCredentials(theURL, scopeDescription, userKey, passwordKey, tokenKey, credentialsProvider, authCache);
326     }
327 
328     /**
329      * Configure pre-emptive credentials for the host/port of the URL when configured in settings for a specific credential-store and
330      * authentication-cache.
331      *
332      * @param theURL      The url for which the credentials should hold
333      * @param scopeDescription        A descriptive text for use in error messages for this credential
334      * @param userKey     The settings property key for a potentially configured configured Basic-auth username
335      * @param passwordKey The settings property key for a potentially configured configured Basic-auth password
336      * @param tokenKey The settings property key for a potentially configured Bearer-auth token
337      * @param theCredentialsStore The credential store that will be set in the HTTP clients context
338      * @param theAuthCache        The authentication cache that will be set in the HTTP clients context
339      * @throws InvalidSettingException When the password is empty or one of the other keys are not found in the settings.
340      */
341     private void configureCredentials(URL theURL, String scopeDescription, String userKey, String passwordKey, String tokenKey,
342                                       CredentialsStore theCredentialsStore, AuthCache theAuthCache)
343             throws InvalidSettingException {
344         final String theUser = settings.getString(userKey);
345         final String thePass = settings.getString(passwordKey);
346         final String theToken = tokenKey != null ? settings.getString(tokenKey) : null;
347         if (theUser == null && thePass == null && theToken == null) {
348             // no credentials configured
349             return;
350         }
351         final String theProtocol = theURL.getProtocol();
352         if ("file".equals(theProtocol)) {
353             // no credentials support for file protocol
354             return;
355         } else if ("http".equals(theProtocol) && (theUser != null && thePass != null)) {
356             LOGGER.warn("Insecure configuration: Basic Credentials are configured to be used over a plain http connection for {}. "
357                     + "Consider migrating to https to guard the credentials.", scopeDescription);
358         } else if ("http".equals(theProtocol) && (theToken != null)) {
359             LOGGER.warn("Insecure configuration: Bearer Credentials are configured to be used over a plain http connection for {}. "
360                     + "Consider migrating to https to guard the credentials.", scopeDescription);
361         } else if (!"https".equals(theProtocol)) {
362             throw new InvalidSettingException("Unsupported protocol in the " + scopeDescription
363                     + " URL; only file, http and https are supported");
364         }
365         if (theToken != null) {
366             HC5CredentialHelper.configurePreEmptiveBearerAuth(theURL, theToken, theCredentialsStore, theAuthCache);
367         } else if (theUser != null && thePass != null) {
368             HC5CredentialHelper.configurePreEmptiveBasicAuth(theURL, theUser, thePass, theCredentialsStore, theAuthCache);
369         }
370     }
371 
372     /**
373      * Retrieves a file from a given URL and saves it to the outputPath.
374      *
375      * @param url        the URL of the file to download
376      * @param outputPath the path to the save the file to
377      * @throws DownloadFailedException       is thrown if there is an error downloading the file
378      * @throws URLConnectionFailureException is thrown when certificate-chain trust errors occur downloading the file
379      * @throws TooManyRequestsException      thrown when a 429 is received
380      * @throws ResourceNotFoundException     thrown when a 404 is received
381      */
382     public void fetchFile(URL url, File outputPath)
383             throws DownloadFailedException, TooManyRequestsException, ResourceNotFoundException, URLConnectionFailureException {
384         fetchFile(url, outputPath, true);
385     }
386 
387     /**
388      * Retrieves a file from a given URL and saves it to the outputPath.
389      *
390      * @param url        the URL of the file to download
391      * @param outputPath the path to the save the file to
392      * @param useProxy   whether to use the configured proxy when downloading
393      *                   files
394      * @throws DownloadFailedException       is thrown if there is an error downloading the file
395      * @throws URLConnectionFailureException is thrown when certificate-chain trust errors occur downloading the file
396      * @throws TooManyRequestsException      thrown when a 429 is received
397      * @throws ResourceNotFoundException     thrown when a 404 is received
398      */
399     public void fetchFile(URL url, File outputPath, boolean useProxy) throws DownloadFailedException,
400             TooManyRequestsException, ResourceNotFoundException, URLConnectionFailureException {
401         try {
402             if ("file".equals(url.getProtocol())) {
403                 final Path p = Paths.get(url.toURI());
404                 Files.copy(p, outputPath.toPath(), StandardCopyOption.REPLACE_EXISTING);
405             } else {
406                 final BasicClassicHttpRequest req;
407                 req = new BasicClassicHttpRequest(Method.GET, url.toURI());
408                 try (CloseableHttpClient hc = useProxy ? httpClientBuilder.build() : httpClientBuilderExplicitNoproxy.build()) {
409                     final SaveToFileResponseHandler responseHandler = new SaveToFileResponseHandler(outputPath);
410                     hc.execute(req, getPreEmptiveAuthContext(), responseHandler);
411                 }
412             }
413         } catch (HttpResponseException hre) {
414             wrapAndThrowHttpResponseException(url.toString(), hre);
415         } catch (SSLHandshakeException ex) {
416             if (ex.getMessage().contains("unable to find valid certification path to requested target")) {
417                 final String msg = String.format("Unable to connect to '%s' - the Java trust store does not contain a trusted root for the cert. "
418                         + "Please see https://github.com/jeremylong/InstallCert for one method of updating the trusted certificates.", url);
419                 throw new URLConnectionFailureException(msg, ex);
420             }
421             final String msg = format("Download failed, unable to copy '%s' to '%s'; %s", url, outputPath.getAbsolutePath(), ex.getMessage());
422             throw new DownloadFailedException(msg, ex);
423         } catch (RuntimeException | URISyntaxException | IOException ex) {
424             final String msg = format("Download failed, unable to copy '%s' to '%s'; %s", url, outputPath.getAbsolutePath(), ex.getMessage());
425             throw new DownloadFailedException(msg, ex);
426         }
427     }
428 
429     private static void wrapAndThrowHttpResponseException(String url, HttpResponseException hre)
430             throws ResourceNotFoundException, TooManyRequestsException, DownloadFailedException {
431         final String messageFormat = "%s - Server status: %d - Server reason: %s";
432         switch (hre.getStatusCode()) {
433             case 404:
434                 throw new ResourceNotFoundException(String.format(messageFormat, url, hre.getStatusCode(), hre.getReasonPhrase()), hre);
435             case 429:
436                 throw new TooManyRequestsException(String.format(messageFormat, url, hre.getStatusCode(), hre.getReasonPhrase()), hre);
437             default:
438                 throw new DownloadFailedException(String.format(messageFormat, url, hre.getStatusCode(), hre.getReasonPhrase()), hre);
439         }
440     }
441 
442     /**
443      * Retrieves a file from a given URL using an ad-hoc created CredentialsProvider if needed
444      * and saves it to the outputPath.
445      *
446      * @param url         the URL of the file to download
447      * @param outputPath  the path to the save the file to
448      * @param useProxy    whether to use the configured proxy when downloading files
449      * @param userKey     The settings property key for a potentially configured configured Basic-auth username
450      * @param passwordKey The settings property key for a potentially configured configured Basic-auth password
451      * @param tokenKey    The settings property key for a potentially configured Bearer-auth token
452      * @throws DownloadFailedException       is thrown if there is an error downloading the file
453      * @throws URLConnectionFailureException is thrown when certificate-chain trust errors occur downloading the file
454      * @throws TooManyRequestsException      thrown when a 429 is received
455      * @throws ResourceNotFoundException     thrown when a 404 is received
456      * @implNote This method should only be used in cases where the target host cannot be determined beforehand from settings, so that ad-hoc
457      * Credentials needs to be constructed for the target URL when the user/password keys point to configured credentials. The method delegates to
458      * {@link #fetchFile(URL, File, boolean)} when credentials are not configured for the given keys or the resource points to a file.
459      */
460     public void fetchFile(URL url, File outputPath, boolean useProxy, String userKey, String passwordKey, String tokenKey)
461             throws DownloadFailedException, TooManyRequestsException, ResourceNotFoundException, URLConnectionFailureException {
462         final boolean basicConfigured = userKey != null && settings.getString(userKey) != null
463                 && passwordKey != null && settings.getString(passwordKey) != null;
464         final boolean tokenConfigured = tokenKey != null && settings.getString(tokenKey) != null;
465         if ("file".equals(url.getProtocol()) || (!basicConfigured && !tokenConfigured)) {
466             // no credentials configured, so use the default fetchFile
467             fetchFile(url, outputPath, useProxy);
468             return;
469         }
470         final String theProtocol = url.getProtocol();
471         if (!("http".equals(theProtocol) || "https".equals(theProtocol))) {
472             throw new DownloadFailedException("Unsupported protocol in the URL; only file, http and https are supported");
473         }
474         try {
475             final HttpClientContext dedicatedAuthContext = HttpClientContext.create();
476             final CredentialsStore dedicatedCredentialStore = new SystemDefaultCredentialsProvider();
477             final AuthCache dedicatedAuthCache = new BasicAuthCache();
478             configureCredentials(url, url.toString(), userKey, passwordKey, tokenKey, dedicatedCredentialStore, dedicatedAuthCache);
479             if (useProxy && proxyAuthScope != null) {
480                 tryConfigureProxyCredentials(dedicatedCredentialStore, dedicatedAuthCache);
481             }
482             dedicatedAuthContext.setCredentialsProvider(dedicatedCredentialStore);
483             dedicatedAuthContext.setAuthCache(dedicatedAuthCache);
484             try (CloseableHttpClient hc = useProxy ? httpClientBuilder.build() : httpClientBuilderExplicitNoproxy.build()) {
485                 final BasicClassicHttpRequest req = new BasicClassicHttpRequest(Method.GET, url.toURI());
486                 final SaveToFileResponseHandler responseHandler = new SaveToFileResponseHandler(outputPath);
487                 hc.execute(req, dedicatedAuthContext, responseHandler);
488             }
489         } catch (HttpResponseException hre) {
490             wrapAndThrowHttpResponseException(url.toString(), hre);
491         } catch (SSLHandshakeException ex) {
492             if (ex.getMessage().contains("unable to find valid certification path to requested target")) {
493                 final String msg = String.format("Unable to connect to '%s' - the Java trust store does not contain a trusted root for the cert. "
494                         + "Please see https://github.com/jeremylong/InstallCert for one method of updating the trusted certificates.", url);
495                 throw new URLConnectionFailureException(msg, ex);
496             }
497             final String msg = format("Download failed, unable to copy '%s' to '%s'; %s", url, outputPath.getAbsolutePath(), ex.getMessage());
498             throw new DownloadFailedException(msg, ex);
499         } catch (RuntimeException | URISyntaxException | IOException ex) {
500             final String msg = format("Download failed, unable to copy '%s' to '%s'; %s", url, outputPath.getAbsolutePath(), ex.getMessage());
501             throw new DownloadFailedException(msg, ex);
502         }
503     }
504 
505     /**
506      * Add the proxy credentials to the CredentialsProvider and AuthCache instances when proxy-authentication is configured in the settings.
507      * @param credentialsProvider The credentialStore to configure the credentials in
508      * @param authCache The AuthCache to cache the pre-empted credentials in
509      */
510     private void tryConfigureProxyCredentials(@NotNull CredentialsStore credentialsProvider, @NotNull AuthCache authCache) {
511         if (proxyPreEmptAuth != null) {
512             credentialsProvider.setCredentials(proxyAuthScope, proxyCreds);
513             authCache.put(proxyHttpHost, proxyPreEmptAuth);
514         }
515     }
516 
517     /**
518      * Posts a payload to the URL and returns the response as a string.
519      *
520      * @param url         the URL to POST to
521      * @param payload     the Payload to post
522      * @param payloadType the string describing the payload's mime-type
523      * @param hdr         Additional headers to add to the HTTP request
524      * @return the content of the response
525      * @throws DownloadFailedException       is thrown if there is an error downloading the file
526      * @throws URLConnectionFailureException is thrown when certificate-chain trust errors occur downloading the file
527      * @throws TooManyRequestsException      thrown when a 429 is received
528      * @throws ResourceNotFoundException     thrown when a 404 is received
529      */
530     public String postBasedFetchContent(URI url, String payload, ContentType payloadType, List<Header> hdr)
531             throws DownloadFailedException, TooManyRequestsException, ResourceNotFoundException, URLConnectionFailureException {
532         try {
533             if (url.getScheme() == null || !url.getScheme().toLowerCase(Locale.ROOT).matches("^https?")) {
534                 throw new IllegalArgumentException("Unsupported protocol in the URL; only http and https are supported");
535             } else {
536                 final BasicClassicHttpRequest req;
537                 req = new BasicClassicHttpRequest(Method.POST, url);
538                 req.setEntity(new StringEntity(payload, payloadType));
539                 for (Header h : hdr) {
540                     req.addHeader(h);
541                 }
542                 final String result;
543                 try (CloseableHttpClient hc = httpClientBuilder.build()) {
544                     result = hc.execute(req, getPreEmptiveAuthContext(), new BasicHttpClientResponseHandler());
545                 }
546                 return result;
547             }
548         } catch (HttpResponseException hre) {
549             wrapAndThrowHttpResponseException(url.toString(), hre);
550             throw new InternalError("wrapAndThrowHttpResponseException will always throw an exception but Java compiler fails to spot it");
551         } catch (SSLHandshakeException ex) {
552             if (ex.getMessage().contains("unable to find valid certification path to requested target")) {
553                 final String msg = String.format("Unable to connect to '%s' - the Java trust store does not contain a trusted root for the cert. "
554                         + "Please see https://github.com/jeremylong/InstallCert for one method of updating the trusted certificates.", url);
555                 throw new URLConnectionFailureException(msg, ex);
556             }
557             final String msg = format("Download failed, error downloading '%s'; %s", url, ex.getMessage());
558             throw new DownloadFailedException(msg, ex);
559         } catch (IOException | RuntimeException ex) {
560             final String msg = format("Download failed, error downloading '%s'; %s", url, ex.getMessage());
561             throw new DownloadFailedException(msg, ex);
562         }
563     }
564 
565     /**
566      * Retrieves a file from a given URL and returns the contents.
567      *
568      * @param url     the URL of the file to download
569      * @param charset The characterset to use to interpret the binary content of the file
570      * @return the content of the file
571      * @throws DownloadFailedException   is thrown if there is an error
572      *                                   downloading the file
573      * @throws TooManyRequestsException  thrown when a 429 is received
574      * @throws ResourceNotFoundException thrown when a 404 is received
575      */
576     public String fetchContent(URL url, Charset charset) throws DownloadFailedException, TooManyRequestsException, ResourceNotFoundException {
577         return fetchContent(url, true, charset);
578     }
579 
580     /**
581      * Retrieves a file from a given URL and returns the contents.
582      *
583      * @param url      the URL of the file to download
584      * @param useProxy whether to use the configured proxy when downloading
585      *                 files
586      * @param charset  The characterset to use to interpret the binary content of the file
587      * @return the content of the file
588      * @throws DownloadFailedException   is thrown if there is an error
589      *                                   downloading the file
590      * @throws TooManyRequestsException  thrown when a 429 is received
591      * @throws ResourceNotFoundException thrown when a 404 is received
592      */
593     public String fetchContent(URL url, boolean useProxy, Charset charset)
594             throws DownloadFailedException, TooManyRequestsException, ResourceNotFoundException {
595         try {
596             final String result;
597             if ("file".equals(url.getProtocol())) {
598                 final Path p = Paths.get(url.toURI());
599                 result = Files.readString(p, charset);
600             } else {
601                 final BasicClassicHttpRequest req;
602                 req = new BasicClassicHttpRequest(Method.GET, url.toURI());
603                 try (CloseableHttpClient hc = useProxy ? httpClientBuilder.build() : httpClientBuilderExplicitNoproxy.build()) {
604                     req.addHeader(HttpHeaders.ACCEPT_CHARSET, charset.name());
605                     final ExplicitCharsetToStringResponseHandler responseHandler = new ExplicitCharsetToStringResponseHandler(charset);
606                     result = hc.execute(req, getPreEmptiveAuthContext(), responseHandler);
607                 }
608             }
609             return result;
610         } catch (HttpResponseException hre) {
611             wrapAndThrowHttpResponseException(url.toString(), hre);
612             throw new InternalError("wrapAndThrowHttpResponseException will always throw an exception but Java compiler fails to spot it");
613         } catch (RuntimeException | URISyntaxException | IOException ex) {
614             final String msg = format("Download failed, error downloading '%s'; %s", url, ex.getMessage());
615             throw new DownloadFailedException(msg, ex);
616         }
617     }
618 
619     /**
620      * Gets a HttpClientContext that supports pre-emptive authentication.
621      * @return A HttpClientContext pre-configured with the authentication cache build from the settings.
622      */
623     public HttpClientContext getPreEmptiveAuthContext() {
624         final HttpClientContext context = HttpClientContext.create();
625         context.setCredentialsProvider(credentialsProvider);
626         context.setAuthCache(authCache);
627         return context;
628     }
629 
630     /**
631      * Gets a pre-configured HttpClient.
632      * Mainly targeted for use in paged resultset scenarios with multiple roundtrips.
633      * @param useProxy Whether to use the configuration that includes proxy-settings
634      * @return A HttpClient pre-configured with the settings.
635      */
636     public CloseableHttpClient getHttpClient(boolean useProxy) {
637         return useProxy ? httpClientBuilder.build() : httpClientBuilderExplicitNoproxy.build();
638     }
639 
640     /**
641      * Download a resource from the given URL and have its content handled by the given ResponseHandler.
642      *
643      * @param url             The url of the resource
644      * @param handler   The responsehandler to handle the response
645      * @param <T>             The return-type for the responseHandler
646      * @return The response handler result
647      * @throws IOException               on I/O Exceptions
648      * @throws TooManyRequestsException  When HTTP status 429 is encountered
649      * @throws ResourceNotFoundException When HTTP status 404 is encountered
650      */
651     public <T> T fetchAndHandle(@NotNull URL url, @NotNull HttpClientResponseHandler<T> handler)
652             throws IOException, TooManyRequestsException, ResourceNotFoundException, URISyntaxException {
653         return fetchAndHandle(url, handler, Collections.emptyList(), true);
654     }
655 
656     /**
657      * Download a resource from the given URL and have its content handled by the given ResponseHandler.
658      *
659      * @param url               The url of the resource
660      * @param handler   The responsehandler to handle the response
661      * @param hdr Additional headers to add to the HTTP request
662      * @param <T>               The return-type for the responseHandler
663      * @return The response handler result
664      * @throws IOException               on I/O Exceptions
665      * @throws TooManyRequestsException  When HTTP status 429 is encountered
666      * @throws ResourceNotFoundException When HTTP status 404 is encountered
667      */
668     public <T> T fetchAndHandle(@NotNull URL url, @NotNull HttpClientResponseHandler<T> handler, @NotNull List<Header> hdr)
669             throws IOException, TooManyRequestsException, ResourceNotFoundException, URISyntaxException {
670         return fetchAndHandle(url, handler, hdr, true);
671     }
672 
673     /**
674      * Download a resource from the given URL and have its content handled by the given ResponseHandler.
675      *
676      * @param url               The url of the resource
677      * @param handler   The responsehandler to handle the response
678      * @param hdr Additional headers to add to the HTTP request
679      * @param useProxy          Whether to use the configured proxy for the connection
680      * @param <T>               The return-type for the responseHandler
681      * @return The response handler result
682      * @throws IOException               on I/O Exceptions
683      * @throws TooManyRequestsException  When HTTP status 429 is encountered
684      * @throws ResourceNotFoundException When HTTP status 404 is encountered
685      */
686     public <T> T fetchAndHandle(@NotNull URL url, @NotNull HttpClientResponseHandler<T> handler, @NotNull List<Header> hdr, boolean useProxy)
687             throws IOException, TooManyRequestsException, ResourceNotFoundException, URISyntaxException {
688         final T data;
689         if ("file".equals(url.getProtocol())) {
690             final Path p = Paths.get(url.toURI());
691             try (InputStream is = Files.newInputStream(p)) {
692                 final HttpEntity dummyEntity = new BasicHttpEntity(is, ContentType.APPLICATION_JSON);
693                 final ClassicHttpResponse dummyResponse = new BasicClassicHttpResponse(200);
694                 dummyResponse.setEntity(dummyEntity);
695                 data = handler.handleResponse(dummyResponse);
696             } catch (HttpException e) {
697                 throw new IllegalStateException("HttpException encountered emulating a HTTP response from a file", e);
698             }
699         } else {
700             try (CloseableHttpClient hc = useProxy ? httpClientBuilder.build() : httpClientBuilderExplicitNoproxy.build()) {
701                 return fetchAndHandle(hc, url, handler, hdr);
702             }
703         }
704         return data;
705     }
706     /**
707      * Download a resource from the given URL and have its content handled by the given ResponseHandler.
708      *
709      * @param client            The HTTP Client to reuse for the request
710      * @param url               The url of the resource
711      * @param handler   The responsehandler to handle the response
712      * @param hdr Additional headers to add to the HTTP request
713      * @param <T>               The return-type for the responseHandler
714      * @return The response handler result
715      * @throws IOException               on I/O Exceptions
716      * @throws TooManyRequestsException  When HTTP status 429 is encountered
717      * @throws ResourceNotFoundException When HTTP status 404 is encountered
718      */
719     public <T> T fetchAndHandle(@NotNull CloseableHttpClient client, @NotNull URL url, @NotNull HttpClientResponseHandler<T> handler,
720                                 @NotNull List<Header> hdr) throws IOException, TooManyRequestsException, ResourceNotFoundException {
721         try {
722             final String theProtocol = url.getProtocol();
723             if (!("http".equals(theProtocol) || "https".equals(theProtocol))) {
724                 throw new DownloadFailedException("Unsupported protocol in the URL; only http and https are supported");
725             }
726             final BasicClassicHttpRequest req = new BasicClassicHttpRequest(Method.GET, url.toURI());
727             for (Header h : hdr) {
728                 req.addHeader(h);
729             }
730             final HttpClientContext context = getPreEmptiveAuthContext();
731             return client.execute(req, context, handler);
732         } catch (HttpResponseException hre) {
733             final String messageFormat = "%s - Server status: %d - Server reason: %s";
734             switch (hre.getStatusCode()) {
735                 case 404:
736                     throw new ResourceNotFoundException(String.format(messageFormat, url, hre.getStatusCode(), hre.getReasonPhrase()));
737                 case 429:
738                     throw new TooManyRequestsException(String.format(messageFormat, url, hre.getStatusCode(), hre.getReasonPhrase()));
739                 default:
740                     throw new IOException(String.format(messageFormat, url, hre.getStatusCode(), hre.getReasonPhrase()));
741             }
742         } catch (RuntimeException | URISyntaxException ex) {
743             final String msg = format("Download failed, unable to retrieve and parse '%s'; %s", url, ex.getMessage());
744             throw new IOException(msg, ex);
745         }
746     }
747 
748     private static class SelectiveProxySelector extends ProxySelector {
749 
750         /**
751          * The suffix-match entries from the nonProxyHosts (those starting with a {@code *}).
752          */
753         private final List<String> suffixMatch = new ArrayList<>();
754         /**
755          * The full host entries from the nonProxyHosts (those <em>not</em> starting with a {@code *}).
756          */
757         private final List<String> fullmatch = new ArrayList<>();
758         /**
759          * The proxy use when no proxy-exception is found.
760          */
761         private final Proxy configuredProxy;
762 
763         SelectiveProxySelector(Proxy httpHost, String[] nonProxyHostsPatterns) {
764             for (String nonProxyHostPattern : nonProxyHostsPatterns) {
765                 if (nonProxyHostPattern.startsWith("*")) {
766                     suffixMatch.add(nonProxyHostPattern.substring(1));
767                 } else {
768                     fullmatch.add(nonProxyHostPattern);
769                 }
770             }
771             this.configuredProxy = httpHost;
772         }
773 
774         @Override
775         public List<Proxy> select(URI uri) {
776             final String theHost = uri.getHost();
777             if (fullmatch.contains(theHost)) {
778                 return Collections.singletonList(Proxy.NO_PROXY);
779             } else {
780                 for (String suffix : suffixMatch) {
781                     if (theHost.endsWith(suffix)) {
782                         return Collections.singletonList(Proxy.NO_PROXY);
783                     }
784                 }
785             }
786             return List.of(configuredProxy);
787         }
788 
789         @Override
790         public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
791             // nothing to be done for this single proxy proxy-selector
792         }
793     }
794 }