001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.rest.client.apachehttpclient50; 018 019import java.io.*; 020import java.util.*; 021import java.util.concurrent.*; 022 023import org.apache.hc.client5.http.config.*; 024import org.apache.hc.client5.http.cookie.*; 025import org.apache.hc.client5.http.impl.*; 026import org.apache.hc.client5.http.impl.classic.*; 027import org.apache.hc.client5.http.protocol.*; 028import org.apache.hc.client5.http.routing.*; 029import org.apache.hc.client5.http.impl.io.*; 030import org.apache.hc.core5.http.*; 031import org.apache.hc.core5.http.io.entity.*; 032import org.apache.hc.core5.http.io.support.*; 033import org.apache.hc.core5.http.message.*; 034import org.apache.hc.core5.http.protocol.*; 035import org.apache.hc.core5.io.*; 036import org.apache.hc.core5.util.*; 037import org.apache.juneau.http.*; 038import org.apache.juneau.http.remote.*; 039import org.apache.juneau.rest.client.*; 040 041/** 042 * {@link HttpTransport} implementation backed by Apache HttpClient 5.x. 043 * 044 * <p> 045 * This transport is auto-discovered via {@link java.util.ServiceLoader} when 046 * {@code org.apache.httpcomponents.client5:httpclient5} is on the classpath. You can also instantiate it explicitly: 047 * 048 * <p class='bjava'> 049 * <jv>transport</jv> = ApacheHc5Transport.<jsm>builder</jsm>() 050 * .httpClient(HttpClients.createDefault()) 051 * .build(); 052 * 053 * <jv>client</jv> = RestClient.<jsm>builder</jsm>() 054 * .transport(<jv>transport</jv>) 055 * .build(); 056 * </p> 057 * 058 * <p> 059 * The transport passes each ordinary request a per-request context that removes the caller's credential headers 060 * ({@link RedirectSecurity#stripOnCrossOrigin()}) from a redirect hop to another origin and from every later hop. On 061 * the client the transport creates itself, a redirect to another origin is therefore followed without those headers. 062 * With a caller-supplied client, the client's own redirect settings and {@code RedirectStrategy} decide which redirects 063 * are followed, and the context guards whatever strategy the client was built with, including 064 * {@code LaxRedirectStrategy}. 065 * 066 * <h5 class='section'>See Also:</h5><ul> 067 * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/NextGenRestClient">Next-Generation REST Client</a> 068 * </ul> 069 * 070 * @since 9.2.1 071 */ 072@SuppressWarnings({ 073 "resource" // httpClient is owned by this transport and closed in close() 074}) 075public final class ApacheHc5Transport implements HttpTransport { 076 077 private final CloseableHttpClient httpClient; 078 private final CloseableHttpClient pinnedHttpClient; 079 private final BasicCookieStore cookieStore; 080 // One further pinned client (own connection pool) per distinct non-empty per-client allow-list, created on first use, 081 // so a connection opened under one allow-list can never be handed to a request with another. 082 private final Map<RemoteUrlPolicy.AllowedRanges,CloseableHttpClient> rangedPinnedHttpClients = new ConcurrentHashMap<>(); 083 084 ApacheHc5Transport(ApacheHc5TransportBuilder builder) { 085 if (builder.httpClient != null) { 086 // A caller-supplied client cannot be given pin-on-connect, so supportsUrlPolicy() reports false for it. 087 this.httpClient = builder.httpClient; 088 this.pinnedHttpClient = null; 089 this.cookieStore = null; 090 } else { 091 // Two clients, one per connection pool, sharing a cookie store so that cookies behave as on one client. 092 this.cookieStore = new BasicCookieStore(); 093 this.httpClient = HttpClients.custom() 094 .setDefaultCookieStore(cookieStore) 095 .setRedirectStrategy(StrippingRedirectStrategy.INSTANCE) 096 .build(); 097 this.pinnedHttpClient = createPinnedHttpClient(cookieStore, PolicyPinningDnsResolver.INSTANCE); 098 } 099 } 100 101 // The redirect strategy of the transport's own client for ordinary requests. DefaultRedirectStrategy refuses a 102 // cross-origin redirect that would replay a sensitive header (every header in the stripped set is marked sensitive), 103 // so the caller would get the 3xx back. Every request on that client is sent with a CredentialGuardingContext, which 104 // removes those headers from such a hop, so the redirect is followed without them instead. Which responses count as 105 // redirects is DefaultRedirectStrategy's own; the method rewrite, the redirect cap and the circular-redirect check are 106 // the redirect executor's, and unchanged. 107 private static final class StrippingRedirectStrategy extends DefaultRedirectStrategy { 108 static final StrippingRedirectStrategy INSTANCE = new StrippingRedirectStrategy(); 109 110 @Override /* DefaultRedirectStrategy */ 111 public boolean isRedirectAllowed(HttpHost currentTarget, HttpHost newTarget, HttpRequest redirect, HttpContext context) { 112 return true; 113 } 114 } 115 116 // The client for SSRF-guard-active @Remote requests. Its connection manager is its own and resolves every host 117 // through PolicyPinningDnsResolver, so every connection in its pool was opened through the pin; a guarded request 118 // can never be handed an idle connection that an unguarded request opened. Redirect handling is disabled so that 119 // PolicyEnforcedRedirects can revalidate every hop. 120 private static CloseableHttpClient createPinnedHttpClient(BasicCookieStore cookieStore, PolicyPinningDnsResolver resolver) { 121 var connectionManager = PoolingHttpClientConnectionManagerBuilder.create() 122 .setDnsResolver(resolver) 123 .build(); 124 return HttpClients.custom() 125 .setConnectionManager(connectionManager) 126 .setDefaultCookieStore(cookieStore) 127 .disableRedirectHandling() 128 .build(); 129 } 130 131 /** 132 * Returns a new builder for this transport. 133 * 134 * @return A new builder. Never <jk>null</jk>. 135 */ 136 public static ApacheHc5TransportBuilder builder() { 137 return new ApacheHc5TransportBuilder(); 138 } 139 140 /** 141 * Returns a new instance backed by a default {@link CloseableHttpClient}. 142 * 143 * @return A new instance. Never <jk>null</jk>. 144 */ 145 public static ApacheHc5Transport create() { 146 return builder().build(); 147 } 148 149 @Override /* HttpTransport */ 150 public TransportResponse execute(TransportRequest request) throws TransportException { 151 if (request.isSsrfGuardActive()) 152 return executeWithPolicy(request); 153 return sendOnce(request, httpClient, buildContext(request, httpClient)); 154 } 155 156 @Override /* HttpTransport */ 157 public boolean supportsUrlPolicy() { 158 return pinnedHttpClient != null; 159 } 160 161 // Juneau-controlled redirect revalidation for a guarded request; every hop goes through sendOncePinned. 162 private TransportResponse executeWithPolicy(TransportRequest request) throws TransportException { 163 return PolicyEnforcedRedirects.execute(request, this::sendOncePinned); 164 } 165 166 // Performs one pin-on-connect hop on the pinned client. A caller-supplied client has no pinned client, so its hops 167 // are sent unpinned on that client; RestRequest never routes a guarded request there (supportsUrlPolicy() is false). 168 // PolicyPinningDnsResolver refuses a host whose every address is denied with an IllegalArgumentException, which 169 // HttpClient propagates unchanged; it is reported as the documented TransportException. 170 private TransportResponse sendOncePinned(TransportRequest request) throws TransportException { 171 try { 172 var client = pinnedClientFor(request); 173 return sendOnce(request, client, buildContext(request, client)); 174 } catch (IllegalArgumentException e) { 175 throw new TransportException("HTTP transport error: " + e.getMessage(), e); 176 } 177 } 178 179 // The pinned client for the request's per-client allow-list: the shared one when it allows nothing, else a dedicated 180 // one (own pool, own resolver) per distinct allow-list. A caller-supplied client has none, and is used as is. 181 private CloseableHttpClient pinnedClientFor(TransportRequest request) { 182 if (pinnedHttpClient == null) 183 return httpClient; 184 var ranges = request.getAllowedAddressRanges(); 185 if (ranges.isEmpty()) 186 return pinnedHttpClient; 187 return rangedPinnedHttpClients.computeIfAbsent(ranges, r -> createPinnedHttpClient(cookieStore, new PolicyPinningDnsResolver(r))); 188 } 189 190 private static TransportResponse sendOnce(TransportRequest request, CloseableHttpClient client, HttpClientContext context) throws TransportException { 191 var hcRequest = buildHcRequest(request); 192 ClassicHttpResponse hcResponse; 193 try { 194 // An explicit target, which a client from HttpClients.createMinimal() requires. 195 hcResponse = client.executeOpen(RoutingSupport.determineHost(hcRequest), hcRequest, context); 196 } catch (IOException | HttpException e) { 197 throw new TransportException("HTTP transport error: " + e.getMessage(), e); 198 } 199 try { 200 return buildTransportResponse(hcResponse); 201 } catch (TransportException | RuntimeException e) { 202 // If response wiring fails the closeCallback never runs, so close here to release the leased connection. 203 closeQuietly(hcResponse); 204 throw e; 205 } 206 } 207 208 private static void closeQuietly(ClassicHttpResponse hcResponse) { 209 try { 210 if (hcResponse instanceof ModalCloseable mc) 211 mc.close(CloseMode.IMMEDIATE); 212 else 213 hcResponse.close(); 214 } catch (IOException e) { 215 // Best-effort cleanup on an already-failing path; nothing more can be done. 216 } 217 } 218 219 @Override /* Closeable */ 220 public void close() throws IOException { 221 try { 222 httpClient.close(); 223 } finally { 224 try { 225 if (pinnedHttpClient != null) 226 pinnedHttpClient.close(); 227 } finally { 228 for (var c : rangedPinnedHttpClients.values()) 229 c.close(); 230 } 231 } 232 } 233 234 // ----------------------------------------------------------------------------------------------------------------- 235 // Internal helpers 236 // ----------------------------------------------------------------------------------------------------------------- 237 238 // The request context. On the client for ordinary requests (the transport's own, or a caller-supplied one) it is a 239 // CredentialGuardingContext, since that client may follow a cross-origin redirect with the caller's credentials; the 240 // pinned clients follow no redirects (PolicyEnforcedRedirects strips per hop). It carries a request-scoped 241 // RequestConfig only for a per-request response (socket read) timeout, so the client's default config applies 242 // otherwise; on a pinned client with neither, the context is null. 243 private HttpClientContext buildContext(TransportRequest request, CloseableHttpClient client) { 244 var timeout = request.getTimeout(); 245 var guarded = client == httpClient; 246 if (timeout == null && ! guarded) 247 return null; 248 var context = guarded ? new CredentialGuardingContext() : HttpClientContext.create(); 249 if (timeout == null) 250 return context; 251 // A context-level RequestConfig replaces (not merges with) the client default, so copy the client's default first. 252 var base = client instanceof Configurable c && c.getConfig() != null ? c.getConfig() : RequestConfig.DEFAULT; 253 context.setRequestConfig(RequestConfig.copy(base).setResponseTimeout(Timeout.ofMilliseconds(Math.max(1, timeout.toMillis()))).build()); 254 return context; 255 } 256 257 private static ClassicHttpRequest buildHcRequest(TransportRequest request) throws TransportException { 258 var builder = ClassicRequestBuilder.create(request.getMethod()).setUri(request.getUri()); 259 for (var h : request.getHeaders()) 260 builder.addHeader(new BasicHeader(h.name(), h.value(), RedactedHeaders.isSensitive(h.name(), RedirectSecurity.stripOnCrossOrigin()))); 261 var body = request.getBody(); 262 if (body != null) 263 builder.setEntity(buildEntity(body)); 264 try { 265 return builder.build(); 266 } catch (Exception e) { 267 throw new TransportException("Failed to build HTTP request: " + e.getMessage(), e); 268 } 269 } 270 271 private static HttpEntity buildEntity(TransportBody body) { 272 var ct = body.getContentType(); 273 var contentType = ct != null ? ContentType.parse(ct) : ContentType.APPLICATION_OCTET_STREAM; 274 return new EntityTemplate(body.getContentLength(), contentType, null, body::writeTo); 275 } 276 277 private static TransportResponse buildTransportResponse(ClassicHttpResponse hcResponse) throws TransportException { 278 var builder = TransportResponse.builder() 279 .statusCode(hcResponse.getCode()) 280 .reasonPhrase(hcResponse.getReasonPhrase()); 281 // A graceful close drains the entity before releasing the connection, which never finishes on an open-ended SSE 282 // stream; close those immediately (discarding the connection) so close() and any blocked reader return promptly. 283 if (isEventStream(hcResponse) && hcResponse instanceof ModalCloseable mc) 284 builder.closeCallback(() -> mc.close(CloseMode.IMMEDIATE)); 285 else 286 builder.closeCallback(hcResponse); 287 for (var h : hcResponse.getHeaders()) 288 builder.header(h.getName(), h.getValue()); 289 var entity = hcResponse.getEntity(); 290 if (entity != null) { 291 try { 292 builder.body(entity.getContent()); 293 } catch (IOException e) { 294 throw new TransportException("Failed to read response body: " + e.getMessage(), e); 295 } 296 } 297 return builder.build(); 298 } 299 300 private static boolean isEventStream(ClassicHttpResponse hcResponse) { 301 var ct = hcResponse.getFirstHeader("Content-Type"); 302 return ct != null && "text/event-stream".equalsIgnoreCase(ct.getValue().split(";", 2)[0].trim()); 303 } 304}