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;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.io.*;
022import java.net.*;
023import java.net.http.*;
024import java.net.http.HttpClient.*;
025import java.net.http.HttpRequest.*;
026import java.net.http.HttpResponse.*;
027import java.util.*;
028
029import org.apache.juneau.http.remote.*;
030
031/**
032 * {@link HttpTransport} implementation backed by the JDK's built-in {@link HttpClient}.
033 *
034 * <p>
035 * This is the <b>default</b> transport used by {@link RestClient}.  It is built into the
036 * {@code juneau-rest-client} artifact, requires no external dependencies, and is always available on Java 11+.
037 *
038 * <p>
039 * It is auto-discovered via {@link java.util.ServiceLoader} and can also be instantiated explicitly:
040 *
041 * <p class='bjava'>
042 *    <jv>transport</jv> = JavaHttpTransport.<jsm>builder</jsm>()
043 *       .httpClient(HttpClient.newHttpClient())
044 *       .build();
045 *
046 *    <jv>client</jv> = RestClient.<jsm>builder</jsm>()
047 *       .transport(<jv>transport</jv>)
048 *       .build();
049 * </p>
050 *
051 * <p>
052 * When a pooled keep-alive connection is torn down by the server before any response bytes are received (an
053 * immediate {@code EOF} surfaced by the JDK client as an {@code IOException} whose message is
054 * {@code "HTTP/1.1 header parser received no bytes"}), this transport replays the request exactly once on a fresh
055 * connection &mdash; but only when {@link TransportRequest#isSafeToReplay()} proves it is safe (an idempotent method
056 * with an absent or repeatable body).  The JDK client already retries body-less {@code GET} requests itself, but not
057 * other idempotent methods (e.g. {@code PUT}/{@code DELETE}); this backstop closes that gap while never replaying a
058 * {@code POST} or any other non-idempotent request.
059 *
060 * <p>
061 * For SSRF-guard-active ({@code @Remote}) requests (see {@link TransportRequest#isSsrfGuardActive()}), this
062 * transport pin-on-connects: it resolves the request host, selects an address that passes
063 * {@link RemoteUrlPolicy}, and rewrites the request URI to that literal address
064 * (preserving the original host via an explicit {@code Host} header) so the socket connects only to the
065 * validated address &mdash; and re-runs the same check on every {@code Location} hop via
066 * {@link PolicyEnforcedRedirects}. The JDK {@link HttpClient} has no connect-time/DNS SPI, so an IP-literal
067 * rewrite cannot preserve TLS SNI/hostname verification for the original hostname; per the locked design, policy
068 * -covered <b>HTTPS</b> requests are therefore refused (fail closed) on this transport &mdash; use a transport
069 * that supports connect-time pinning (e.g. Apache HttpClient 4.5/5), or set {@code allowPrivateUrls(true)} if the
070 * target is an intentional local-dev/intranet endpoint. A policy-covered request is also refused if the
071 * underlying {@link HttpClient} was built with automatic redirect-following enabled ({@link Redirect#NORMAL} or
072 * {@link Redirect#ALWAYS}), since this transport cannot re-validate hops that client follows on its own; build it
073 * with the default {@link Redirect#NEVER} (the JDK default) to let this transport's own redirect loop apply.
074 *
075 * <h5 class='section'>See Also:</h5><ul>
076 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/NextGenRestClient">Next-Generation REST Client</a>
077 * </ul>
078 *
079 * @since 9.2.1
080 */
081@SuppressWarnings({
082   "resource" // Not owned here; lifecycle is managed by the surrounding context
083})
084public final class JavaHttpTransport implements HttpTransport {
085
086   private static final String RESTRICTED_HEADERS_PROPERTY = "jdk.httpclient.allowRestrictedHeaders";
087
088   static {
089      // Best-effort: the JDK HttpClient rejects a caller-set "Host" header by default -- a fixed, JVM-wide,
090      // first-touch-wins setting cached the first time any HttpRequest.Builder.header() call reaches the JDK's
091      // internal restricted-header table. Setting this here, as early as this class is loaded (typically before
092      // any unrelated HttpClient usage elsewhere in the same JVM), lets HTTP pin-on-connect preserve the
093      // original Host header for virtual-hosted targets after the IP-literal rewrite. If some other code
094      // already forced that first touch, this has no effect and sendOncePinned() below fails closed instead of
095      // silently sending the wrong Host.
096      var existing = System.getProperty(RESTRICTED_HEADERS_PROPERTY);
097      if (ib(existing))
098         System.setProperty(RESTRICTED_HEADERS_PROPERTY, "host");
099      else if (! existing.toLowerCase(Locale.ROOT).contains("host"))
100         System.setProperty(RESTRICTED_HEADERS_PROPERTY, existing + ",host");
101      // Raw System property access is intentional: the JDK reads this value from System properties only, so Settings overrides/sources must not be consulted.
102   }
103
104   private final HttpClient httpClient;
105
106   JavaHttpTransport(JavaHttpTransportBuilder builder) {
107      this.httpClient = builder.httpClient != null ? builder.httpClient : HttpClient.newHttpClient();
108   }
109
110   /**
111    * Returns a new builder for this transport.
112    *
113    * @return A new builder. Never <jk>null</jk>.
114    */
115   public static JavaHttpTransportBuilder builder() {
116      return new JavaHttpTransportBuilder();
117   }
118
119   /**
120    * Returns a new instance backed by a default {@link HttpClient}.
121    *
122    * @return A new instance. Never <jk>null</jk>.
123    */
124   public static JavaHttpTransport create() {
125      return builder().build();
126   }
127
128   @Override /* HttpTransport */
129   public TransportResponse execute(TransportRequest request) throws TransportException {
130      if (request.isSsrfGuardActive())
131         return executeWithPolicy(request);
132      try {
133         return sendOnce(request);
134      } catch (StaleConnectionException e) {
135         // A pooled keep-alive connection was torn down by the server before any response was received.
136         // Replay once on a fresh connection, but only when it is provably safe (idempotent + repeatable body).
137         if (! request.isSafeToReplay())
138            throw e.asTransportException();
139         try {
140            return sendOnce(request);
141         } catch (StaleConnectionException e2) {
142            throw e2.asTransportException();
143         }
144      }
145   }
146
147   @Override /* HttpTransport */
148   public boolean supportsUrlPolicy() {
149      return true;
150   }
151
152   // Runs the Juneau-controlled redirect loop, pinning each hop to a policy-allowed address.
153   private TransportResponse executeWithPolicy(TransportRequest request) throws TransportException {
154      if (httpClient.followRedirects() != Redirect.NEVER)
155         throw new TransportException("Refusing to send a policy-covered @Remote request through a "
156            + "JavaHttpTransport whose HttpClient auto-follows redirects (" + httpClient.followRedirects()
157            + "); this transport cannot re-validate hops that the client follows on its own. Build the "
158            + "HttpClient with the default Redirect.NEVER, or set allowPrivateUrls(true) if this is an "
159            + "intentional local-dev/intranet target.");
160      return PolicyEnforcedRedirects.execute(request, this::sendOncePinned);
161   }
162
163   // Performs one pin-on-connect hop: resolves and pins the request host, then sends without following redirects.
164   private TransportResponse sendOncePinned(TransportRequest request) throws TransportException {
165      var uri = request.getUri();
166      var scheme = uri.getScheme();
167      if (neqic("http", scheme))
168         throw new TransportException("JavaHttpTransport cannot preserve TLS SNI/hostname verification while "
169            + "pinning the resolved address (the JDK HttpClient has no connect-time/DNS SPI); refusing "
170            + "(fail closed) a policy-covered HTTPS request: " + uri
171            + ".  Use a transport with connect-time pinning support (e.g. Apache HttpClient 4.5/5), or set "
172            + "allowPrivateUrls(true) if this is an intentional local-dev/intranet target.");
173      InetAddress pinned;
174      try {
175         pinned = RemoteUrlPolicy.selectAllowedAddress(requireHost(uri), false, RemoteUrlPolicy.AddressResolver.DEFAULT, request.getAllowedAddressRanges());
176      } catch (UnknownHostException e) {
177         throw new TransportException("Could not resolve @Remote request host: " + uri.getHost(), e);
178      } catch (IllegalArgumentException e) {
179         // A refused pin (every resolved address is denied) is reported as the documented TransportException.
180         throw new TransportException("HTTP transport error: " + e.getMessage(), e);
181      }
182      URI pinnedUri;
183      try {
184         pinnedUri = new URI(pinnedUriString(scheme, pinned, uri));
185      } catch (URISyntaxException e) {
186         throw new TransportException("Could not build a pinned request URI for: " + uri, e);
187      }
188      var hostHeader = uri.getPort() >= 0 ? uri.getHost() + ":" + uri.getPort() : uri.getHost();
189      try {
190         return sendOnce(request, pinnedUri, hostHeader);
191      } catch (StaleConnectionException e) {
192         throw e.asTransportException();
193      }
194   }
195
196   // java.net.URI parses some host names (an underscore name such as my_service) with a null host, which
197   // InetAddress would resolve as the loopback address; refuse such a URI by name instead.
198   private static String requireHost(URI uri) {
199      var host = uri.getHost();
200      if (host == null)
201         throw iaex("Request URI has no host that java.net.URI can parse, so it cannot be pinned: %s", uri);
202      return host;
203   }
204
205   // Performs a single HTTP exchange.  Throws StaleConnectionException (a retryable signal) when the failure is a
206   // pre-response stale-connection failure; throws TransportException for every other failure.
207   private TransportResponse sendOnce(TransportRequest request) throws TransportException, StaleConnectionException {
208      return sendOnce(request, request.getUri(), null);
209   }
210
211   // Performs a single HTTP exchange against overrideUri (the pinned address) instead of request.getUri(),
212   // adding an explicit Host header (the original hostname[:port]) when hostHeader is non-null so virtual-hosting
213   // still works after the IP-literal rewrite.
214   private TransportResponse sendOnce(TransportRequest request, URI overrideUri, String hostHeader) throws TransportException, StaleConnectionException {
215      HttpRequest jdkRequest;
216      try {
217         jdkRequest = buildJdkRequest(request, overrideUri, hostHeader);
218      } catch (IllegalArgumentException e) {
219         // The JDK refuses a URI it cannot use (for example an underscore host name, which java.net.URI parses
220         // with a null host) or an invalid header.
221         if (hostHeader == null)
222            throw new TransportException("Could not build the HTTP request for " + request.getUri() + ": " + e.getMessage(), e);
223         // With hostHeader set, the JDK rejected the "Host" header override -- i.e. the static
224         // allowRestrictedHeaders attempt above lost the first-touch race to some other HttpClient usage in this
225         // JVM. Fail closed rather than silently pinning without preserving the original Host.
226         throw new TransportException("Could not preserve the original Host header while pin-on-connecting a "
227            + "policy-covered HTTP request (the JDK HttpClient's restricted-header allowlist was already "
228            + "initialized elsewhere in this JVM); refusing (fail closed): " + request.getUri()
229            + ".  Set the JVM flag -Djdk.httpclient.allowRestrictedHeaders=host, use a transport with native "
230            + "connect-time pinning support (e.g. Apache HttpClient 4.5/5), or set allowPrivateUrls(true) if "
231            + "this is an intentional local-dev/intranet target.", e);
232      }
233      HttpResponse<InputStream> jdkResponse;
234      try {
235         jdkResponse = httpClient.send(jdkRequest, BodyHandlers.ofInputStream());
236      } catch (IOException e) {
237         // httpClient.send() only returns once the response headers have been read, so any IOException here
238         // occurred before any response bytes were received.
239         if (TransportException.isStaleConnectionFailure(e))
240            throw new StaleConnectionException(e);
241         throw new TransportException("HTTP transport error: " + e.getMessage(), e);
242      } catch (InterruptedException e) {
243         Thread.currentThread().interrupt();
244         throw new TransportException("HTTP request interrupted", e);
245      }
246      return buildTransportResponse(jdkResponse);
247   }
248
249   // -----------------------------------------------------------------------------------------------------------------
250   // Internal helpers
251   // -----------------------------------------------------------------------------------------------------------------
252
253   // Builds a URI string identical to "uri" except with its host replaced by the pinned literal address, by
254   // concatenating uri's already-percent-encoded raw components -- NOT via the multi-arg java.net.URI
255   // constructor, which treats its arguments as decoded components and would double-encode any "%" already
256   // present in the raw path/query/fragment (e.g. "%20" would become "%2520").
257   private static String pinnedUriString(String scheme, InetAddress pinned, URI uri) {
258      var hostLiteral = pinned instanceof Inet6Address ? "[" + pinned.getHostAddress() + "]" : pinned.getHostAddress();
259      var sb = new StringBuilder(scheme).append("://").append(hostLiteral);
260      if (uri.getPort() >= 0)
261         sb.append(':').append(uri.getPort());
262      if (uri.getRawPath() != null)
263         sb.append(uri.getRawPath());
264      if (uri.getRawQuery() != null)
265         sb.append('?').append(uri.getRawQuery());
266      if (uri.getRawFragment() != null)
267         sb.append('#').append(uri.getRawFragment());
268      return sb.toString();
269   }
270
271   private static HttpRequest buildJdkRequest(TransportRequest request, URI uri, String hostHeader) {
272      var builder = HttpRequest.newBuilder().uri(uri).method(request.getMethod(), buildBodyPublisher(request.getBody()));
273      if (hostHeader != null)
274         builder.header("Host", hostHeader);
275      for (var h : request.getHeaders())
276         builder.header(h.name(), h.value());
277      var timeout = request.getTimeout();
278      if (timeout != null)
279         builder.timeout(timeout);
280      return builder.build();
281   }
282
283   @SuppressWarnings({
284      "java:S2095" // PipedInputStream closed by HttpClient when body publishing finishes; PipedOutputStream closed in writer thread try-with-resources
285   })
286   private static BodyPublisher buildBodyPublisher(TransportBody body) {
287      if (body == null)
288         return BodyPublishers.noBody();
289      // Use a pipe so body.writeTo() streams directly to the JDK client without full in-memory buffering.
290      // The writer runs on a daemon thread; the JDK client reads from the PipedInputStream on its own threads.
291      // The supplier runs when the client publishes the request body (not when the HttpRequest is built).
292      var publisher = BodyPublishers.ofInputStream(() -> {
293         var in = new PipedInputStream();
294         try {
295            var out = new PipedOutputStream(in);
296            var writer = new Thread(() -> {
297               try (out) {
298                  body.writeTo(out);
299               } catch (IOException e) {
300                  // Closing the pipe on error causes the reader to see an IOException,
301                  // which propagates to the JDK client as a send failure.
302               }
303            }, "juneau-ng-body-writer");
304            writer.setDaemon(true);
305            writer.start();
306            return in;
307         } catch (IOException e) {
308            try {
309               in.close();
310            } catch (IOException e2) {
311               e.addSuppressed(e2);
312            }
313            throw new UncheckedIOException(e);
314         }
315      });
316      // A repeatable body with a known length is sent fixed-length (Content-Length) rather than chunked;
317      // unknown-length (or non-repeatable streaming) bodies keep chunked transfer encoding.
318      var length = body.getContentLength();
319      if (body.isRepeatable() && length >= 0)
320         return length == 0 ? BodyPublishers.noBody() : BodyPublishers.fromPublisher(publisher, length);
321      return publisher;
322   }
323
324   private static TransportResponse buildTransportResponse(HttpResponse<InputStream> jdkResponse) {
325      var body = jdkResponse.body();
326      var builder = TransportResponse.builder()
327         .statusCode(jdkResponse.statusCode())
328         .body(body)
329         .closeCallback(body);
330      jdkResponse.headers().map().forEach((name, values) ->
331         values.forEach(value -> builder.header(name, value)));
332      return builder.build();
333   }
334
335   // Internal signal that a pre-response stale-connection failure occurred and may be retried once.  Carries the
336   // original cause so the caller can build the user-visible TransportException if the retry is not attempted or
337   // also fails.
338   private static final class StaleConnectionException extends Exception {
339      private static final long serialVersionUID = 1L;
340
341      StaleConnectionException(Throwable cause) {
342         super(cause);
343      }
344
345      TransportException asTransportException() {
346         return new TransportException("HTTP transport error: " + getCause().getMessage(), getCause());
347      }
348   }
349}