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}