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}