001/*
002 *  Copyright 2019 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.core.util;
017
018import java.io.IOException;
019import java.net.HttpURLConnection;
020import java.net.ProtocolException;
021import java.net.SocketTimeoutException;
022import java.net.URI;
023import java.net.URISyntaxException;
024import java.net.URL;
025import java.net.UnknownHostException;
026import java.util.Collections;
027import java.util.HashMap;
028import java.util.Map;
029import java.util.Map.Entry;
030import java.util.Optional;
031import java.util.concurrent.TimeUnit;
032import java.util.regex.Pattern;
033
034import javax.net.ssl.SSLHandshakeException;
035
036import org.apache.avalon.framework.activity.Disposable;
037import org.apache.avalon.framework.activity.Initializable;
038import org.apache.avalon.framework.component.Component;
039import org.apache.commons.lang3.RegExUtils;
040import org.apache.commons.lang3.StringUtils;
041import org.apache.hc.client5.http.ContextBuilder;
042import org.apache.hc.client5.http.classic.HttpClient;
043import org.apache.hc.client5.http.classic.methods.HttpGet;
044import org.apache.hc.client5.http.classic.methods.HttpHead;
045import org.apache.hc.client5.http.classic.methods.HttpPost;
046import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
047import org.apache.hc.client5.http.config.ConnectionConfig;
048import org.apache.hc.client5.http.config.RequestConfig;
049import org.apache.hc.client5.http.cookie.BasicCookieStore;
050import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
051import org.apache.hc.client5.http.impl.classic.HttpClients;
052import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager;
053import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
054import org.apache.hc.client5.http.protocol.HttpClientContext;
055import org.apache.hc.core5.http.ClassicHttpRequest;
056import org.apache.hc.core5.http.HttpHeaders;
057import org.apache.hc.core5.http.io.HttpClientResponseHandler;
058import org.apache.hc.core5.io.CloseMode;
059import org.apache.hc.core5.util.Timeout;
060import org.slf4j.Logger;
061import org.slf4j.LoggerFactory;
062
063import org.ametys.core.ui.Callable;
064import org.ametys.core.util.IPRestrictedSocket.IPRestrictedException;
065import org.ametys.runtime.config.Config;
066
067/**
068 * Utility class for HTTP urls.
069 */
070public class HttpUtils implements Component, Initializable, Disposable
071{
072    /** The Avalon role */
073    public static final String ROLE = HttpUtils.class.getName();
074    
075    /** Regexp for HTTP url */
076    public static final Pattern HTTP_URL_VALIDATOR = Pattern.compile("^(https?:\\/\\/.+)?$");
077    
078    /** The status of HTTP check */
079    public static enum HttpCheck
080    {
081        /** All right */
082        SUCCESS,
083        /** Server error. */
084        SERVER_ERROR,
085        /** URL not found.*/
086        NOT_FOUND,
087        /** Unauthorized. */
088        UNAUTHORIZED,
089        /** Timeout (too long) */
090        TIMEOUT,
091        /** A redirect occurs */
092        REDIRECT,
093        /** Security level error */
094        SECURITY_LEVEL_ERROR,
095        /** No HTTP url */
096        NOT_HTTP
097        
098    }
099    
100    /**
101     * HTTP Status-Code 307: Temporay Redirect
102     */
103    public static final int HTTP_REDIRECT_TEMP = 307;
104    
105    /**
106     * HTTP Status-Code 308: Temporay Redirect
107     */
108    public static final int HTTP_REDIRECT_PERM = 308;
109    
110    
111    private static Logger __logger = LoggerFactory.getLogger(HttpUtils.class);
112
113    private static CloseableHttpClient __httpClient;
114    private static CloseableHttpClient __unRestrictedHttpClient;
115    
116    public void initialize() throws Exception
117    {
118        __httpClient = createHttpClient(-1, -1, true);
119        __unRestrictedHttpClient = createHttpClient(-1, -1, false);
120    }
121    
122    public void dispose()
123    {
124        __httpClient.close(CloseMode.GRACEFUL);
125        __unRestrictedHttpClient.close(CloseMode.GRACEFUL);
126    }
127    
128    /**
129     * Prepare the connection to the remote URL
130     * @param httpUrl The HTTP URL
131     * @param userAgent The user agent. Can be null.
132     * @param method The method for the URL request. Can be null.
133     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
134     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
135     * @param followRedirects Sets to true to follow HTTP redirects
136     * @param requestHeaders The request headers.
137     * @param responseHandler The handler that transform the HTTP response into the execution response
138     * @param <T> The type of the value returned by the execution
139     * @return The result obtained by the response handler
140     * @throws IOException if an I/O exception occurs or the URL is invalid
141     */
142    public static <T> T executeRequest(String httpUrl, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects, Map<String, String> requestHeaders, HttpClientResponseHandler<? extends T> responseHandler) throws IOException
143    {
144        return executeRequest(httpUrl, userAgent, method, timeout, readTimeOut, followRedirects, requestHeaders, false, responseHandler);
145    }
146    /**
147     * Prepare the connection to the remote URL
148     * @param httpUrl The HTTP URL
149     * @param userAgent The user agent. Can be null.
150     * @param method The method for the URL request. Can be null.
151     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
152     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
153     * @param followRedirects Sets to true to follow HTTP redirects
154     * @param requestHeaders The request headers.
155     * @param ignoreRestriction true to bypass security restriction on HTTP client
156     * @param responseHandler The handler that transform the HTTP response into the execution response
157     * @param <T> The type of the value returned by the execution
158     * @return The result obtained by the response handler
159     * @throws IOException if an I/O exception occurs or the URL is invalid
160     */
161    public static <T> T executeRequest(String httpUrl, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects, Map<String, String> requestHeaders, boolean ignoreRestriction, HttpClientResponseHandler<? extends T> responseHandler) throws IOException
162    {
163        try
164        {
165            ClassicHttpRequest request = buildRequest(new URI(httpUrl), userAgent, method, timeout, readTimeOut, followRedirects, 5, requestHeaders);
166            
167            return _executeRequest(request, ignoreRestriction, responseHandler);
168        }
169        catch (URISyntaxException e)
170        {
171            throw new IOException(e);
172        }
173    }
174
175    private static <T> T _executeRequest(ClassicHttpRequest request, boolean ignoreRestriction, HttpClientResponseHandler<? extends T> responseHandler) throws IOException
176    {
177        // Provide a per request cookie store to isolate connection made by the client
178        HttpClientContext context = ContextBuilder.create()
179            .useCookieStore(new BasicCookieStore())
180            .build();
181        
182        return (ignoreRestriction ? __unRestrictedHttpClient : __httpClient).execute(request, context, responseHandler);
183    }
184    
185    /**
186     * build a request to the remote URI
187     * @param uri The URI to request
188     * @param userAgent The user agent. Can be null.
189     * @param method The method for the URL request. Can be null.
190     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
191     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
192     * @param followRedirects Sets to true to follow HTTP redirects
193     * @param maxRedirects The maximum number of redirects to follow
194     * @param requestHeaders The request headers.
195     * @return The HTTP request
196     * @throws IOException if an I/O exception occurs
197     */
198    public static ClassicHttpRequest buildRequest(URI uri, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects, int maxRedirects, Map<String, String> requestHeaders) throws IOException
199    {
200        HttpUriRequestBase request = switch (method)
201        {
202            case null -> new HttpGet(uri);
203            case HttpGet.METHOD_NAME -> new HttpGet(uri);
204            case HttpPost.METHOD_NAME -> new HttpPost(uri);
205            case HttpHead.METHOD_NAME -> new HttpHead(uri);
206            default -> throw new ProtocolException("Unsupported http method: " + method);
207        };
208
209        RequestConfig config = RequestConfig.custom()
210            .setRedirectsEnabled(followRedirects)
211            .setMaxRedirects(maxRedirects)
212            .setConnectionRequestTimeout(timeout, TimeUnit.MILLISECONDS)
213            .setResponseTimeout(readTimeOut, TimeUnit.MILLISECONDS)
214            .build();
215        
216        request.setConfig(config);
217        
218        for (Entry<String, String> headers : requestHeaders.entrySet())
219        {
220            request.setHeader(headers.getKey(), headers.getValue());
221        }
222        
223        if (userAgent != null)
224        {
225            request.setHeader(HttpHeaders.USER_AGENT, userAgent);
226        }
227        
228        return request;
229    }
230    
231    /**
232     * Check the HTTP url
233     * @param httpUrl The HTTP url to test
234     * @param userAgent The user agent. Can be null.
235     * @param method The method for teh URL request. Can be null.
236     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
237     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
238     * @param followRedirects Sets to true to follow HTTP redirects
239     * @return The URL connection
240     */
241    public static HttpCheckReport checkHttpUrl(String httpUrl, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects)
242    {
243        return checkHttpUrl(httpUrl, userAgent, method, timeout, readTimeOut, followRedirects, Collections.EMPTY_MAP);
244    }
245    
246    /**
247     * Check the HTTP url
248     * @param httpUrl The url to test
249     * @param userAgent The user agent. Can be null.
250     * @param method The method for teh URL request. Can be null.
251     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
252     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
253     * @param followRedirects Sets to true to follow HTTP redirects
254     * @param requestHeaders The request headers.
255     * @return The URL connection
256     */
257    public static HttpCheckReport checkHttpUrl(String httpUrl, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects, Map<String, String> requestHeaders)
258    {
259        if (!HTTP_URL_VALIDATOR.matcher(StringUtils.defaultIfEmpty(httpUrl, "")).matches())
260        {
261            __logger.debug("Url '{}' is not a valid HTTP url", httpUrl);
262            return new HttpCheckReport(HttpCheck.NOT_HTTP);
263        }
264        
265        try
266        {
267            return _checkHttpUrl(new URI(httpUrl), userAgent, method, timeout, readTimeOut, followRedirects, requestHeaders, 5);
268        }
269        catch (URISyntaxException e)
270        {
271            __logger.debug("Unable to parse '{}' as a HTTP url", httpUrl, e);
272            return new HttpCheckReport(HttpCheck.NOT_HTTP);
273        }
274    }
275    
276    /**
277     * Check the HTTP url
278     * @param url The url to test
279     * @param userAgent The user agent. Can be null.
280     * @param method The method for teh URL request. Can be null.
281     * @param timeout The connection timeout in milliseconds. Set to -1 to not set a timeout.
282     * @param readTimeOut The read timeout in milliseconds. Set to -1 to not set a timeout.
283     * @param followRedirects Sets to true to follow HTTP redirects
284     * @param requestHeaders The request headers.
285     * @return The URL connection
286     */
287    public static HttpCheckReport checkHttpUrl(URL url, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects, Map<String, String> requestHeaders)
288    {
289        try
290        {
291            return _checkHttpUrl(url.toURI(), userAgent, method, timeout, readTimeOut, followRedirects, requestHeaders, 5);
292        }
293        catch (URISyntaxException e)
294        {
295            __logger.debug("Invalid URI: " + url.toString());
296            return new HttpCheckReport(HttpCheck.NOT_HTTP);
297        }
298    }
299    
300    private static HttpCheckReport _checkHttpUrl(URI uri, String userAgent, String method, int timeout, int readTimeOut, boolean followRedirects, Map<String, String> requestHeaders, int maxRedirects)
301    {
302        try
303        {
304            ClassicHttpRequest request = buildRequest(uri, userAgent, method, timeout, readTimeOut, followRedirects, maxRedirects, requestHeaders);
305            return _executeRequest(request, false, response -> {
306                if (response.getCode() == HttpURLConnection.HTTP_OK)
307                {
308                    __logger.debug("Check of URL '{}' successed", uri);
309                    return new HttpCheckReport(HttpCheck.SUCCESS);
310                }
311                else if (response.getCode() == HttpURLConnection.HTTP_MOVED_TEMP /* 302 */
312                        || response.getCode() == HttpURLConnection.HTTP_MOVED_PERM /* 301 */
313                        || response.getCode() == HttpURLConnection.HTTP_SEE_OTHER /* 303 */
314                        || response.getCode() == HTTP_REDIRECT_TEMP /* 307 */
315                        || response.getCode() == HTTP_REDIRECT_PERM /* 308 */)
316                {
317                    return new HttpCheckReport(HttpCheck.REDIRECT);
318                }
319                else
320                {
321                    int responseCode = response.getCode();
322                    
323                    __logger.debug("Check of URL '{}' returns the status code {}", uri, responseCode);
324                    
325                    switch (responseCode)
326                    {
327                        case HttpURLConnection.HTTP_NOT_FOUND:
328                            return new HttpCheckReport(HttpCheck.NOT_FOUND);
329                        case HttpURLConnection.HTTP_FORBIDDEN:
330                        case HttpURLConnection.HTTP_UNAUTHORIZED:
331                            return new HttpCheckReport(HttpCheck.UNAUTHORIZED);
332                        case HttpURLConnection.HTTP_INTERNAL_ERROR:
333                        default:
334                            return new HttpCheckReport(HttpCheck.SERVER_ERROR, Optional.ofNullable(response.getReasonPhrase()));
335                    }
336                }
337            });
338        }
339        catch (SSLHandshakeException e)
340        {
341            __logger.debug("Certificate error for URL '{}'", uri, e);
342            return new HttpCheckReport(HttpCheck.SECURITY_LEVEL_ERROR, Optional.ofNullable(e.getMessage()));
343        }
344        catch (SocketTimeoutException e)
345        {
346            __logger.debug("Aborting test for URL '{}' because too long", uri, e);
347            return new HttpCheckReport(HttpCheck.TIMEOUT);
348        }
349        catch (UnknownHostException e)
350        {
351            __logger.debug("Unknown host for URL '{}'", uri, e);
352            return new HttpCheckReport(HttpCheck.NOT_FOUND);
353        }
354        catch (IPRestrictedException e)
355        {
356            __logger.debug("Access to '{}' is restricted", uri, e);
357            return new HttpCheckReport(HttpCheck.UNAUTHORIZED);
358        }
359        catch (IOException e)
360        {
361            __logger.debug("Cannot test URL '{}'", uri, e);
362            return new HttpCheckReport(HttpCheck.SERVER_ERROR, Optional.ofNullable(e.getMessage()));
363        }
364    }
365    
366    /**
367     * Method to check a HTTP url from client side.
368     * The HTTP redirects will be followed.
369     * @param httpUrl the http url to check
370     * @return the result of the check
371     */
372    @Callable (rights = Callable.NO_CHECK_REQUIRED) // Assume no right check (use by 'edition.url-reference' widget)
373    public Map<String, Object> checkHttpUrl(String httpUrl)
374    {
375        Map<String, Object> result = new HashMap<>();
376        
377        HttpCheckReport report = checkHttpUrl(httpUrl, null, null, 2000, 2000, true);
378        result.put("success", HttpCheck.SUCCESS.equals(report.status()));
379        if (report.message().isPresent())
380        {
381            result.put("message", report.message().get());
382        }
383        result.put("checkResult", report.status().name());
384        
385        return result;
386    }
387    
388    /**
389     * Represent the result of a check and a potential message
390     * @param status the result status
391     * @param message the message if needed
392     */
393    public record HttpCheckReport(HttpCheck status, Optional<String> message) {
394        /**
395         * Create a report with the provided status and no message.
396         * @param result the result status
397         */
398        public HttpCheckReport(HttpCheck result)
399        {
400            this(result, Optional.empty());
401        }
402    }
403    
404    /**
405     * Create ands return a configured, ready to use, {@link HttpClient}.<br>
406     * This method is well suited to create a client dedicated to a single route.
407     * @param maxConnections the maximum simultaneous connections. If zero or negative, defaults to HttpClient's default values.
408     * @param timeout the socket and connect timeout, in seconds. If zero or negative, defaults to HttpClient's default values.
409     * @return a configured {@link HttpClient}
410     */
411    public static CloseableHttpClient createHttpClient(int maxConnections, int timeout)
412    {
413        return createHttpClient(maxConnections, timeout, true);
414    }
415    
416    /**
417     * Create ands return a configured, ready to use, {@link HttpClient}.<br>
418     * This method is well suited to create a client dedicated to a single route.
419     * @param maxConnections the maximum simultaneous connections. If zero or negative, defaults to HttpClient's default values.
420     * @param timeout the socket and connect timeout, in seconds. If zero or negative, defaults to HttpClient's default values.
421     * @param blockInternal true to prevent request to restricted IP address (as defined in configuration)
422     * @return a configured {@link HttpClient}
423     */
424    public static CloseableHttpClient createHttpClient(int maxConnections, int timeout, boolean blockInternal)
425    {
426        ConnectionConfig connectionConfig = ConnectionConfig.custom()
427                                                            .setConnectTimeout(timeout > 0 ? Timeout.ofSeconds(timeout) : null)
428                                                            .setSocketTimeout(timeout > 0 ? Timeout.ofSeconds(timeout) : null)
429                                                            .build();
430        
431        PoolingHttpClientConnectionManagerBuilder connectionManagerBuilder = _getConnectionManagerBuilder(blockInternal);
432        
433        PoolingHttpClientConnectionManager connectionManager = connectionManagerBuilder
434                                                                    .setDefaultConnectionConfig(connectionConfig)
435                                                                    .setMaxConnTotal(maxConnections > 0 ? maxConnections : PoolingHttpClientConnectionManager.DEFAULT_MAX_TOTAL_CONNECTIONS)
436                                                                    .setMaxConnPerRoute(maxConnections > 0 ? maxConnections : PoolingHttpClientConnectionManager.DEFAULT_MAX_CONNECTIONS_PER_ROUTE)
437                                                                    .build();
438        
439        return HttpClients.custom()
440                          .setConnectionManager(connectionManager)
441                          .useSystemProperties()
442                          .build();
443    }
444
445    private static PoolingHttpClientConnectionManagerBuilder _getConnectionManagerBuilder(boolean blockInternal)
446    {
447        if (blockInternal)
448        {
449            Config config = Config.getInstance();
450            if (config != null)
451            {
452                String rule = config.getValue("runtime.ssrf.block.rule");
453                if (StringUtils.isNotBlank(rule))
454                {
455                    return IPRestrictedConnectionManagerBuilder.create(Pattern.compile(rule));
456                }
457            }
458        }
459        
460        return PoolingHttpClientConnectionManagerBuilder.create();
461    }
462    
463    /**
464     * Strips the end of an uri to remove "*.html" ends and "/" ending characters
465     * @param uri The uri to edit
466     * @return The edited uri
467     */
468    public static String sanitize(String uri)
469    {
470        return StringUtils.stripEnd(RegExUtils.removePattern((CharSequence) uri, "[^/]*.html$"), "/");
471    }
472}