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 — 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 — 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 — 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}