diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/Exchange.java b/src/java.net.http/share/classes/jdk/internal/net/http/Exchange.java index c50a4922e80a4..897845345ce80 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/Exchange.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/Exchange.java @@ -28,6 +28,7 @@ import java.io.IOException; import java.net.ProtocolException; import java.net.http.HttpClient.Version; +import java.net.http.HttpHeaders; import java.time.Duration; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -64,6 +65,8 @@ final class Exchange { volatile CompletableFuture> exchangeCF; volatile CompletableFuture bodyIgnored; volatile boolean streamLimitReached; + volatile Response cachedProxyResponse; // cached response from proxy 407 + volatile byte[] cachedProxyBody; // cached body from proxy 407 response // used to record possible cancellation raised before the exchImpl // has been established. @@ -228,6 +231,30 @@ public CompletableFuture readBodyAsync(HttpResponse.BodyHandler handler) { // an HTTP/2 tunnel through an HTTP/1.1 proxy) if (bodyIgnored != null) return MinimalFuture.completedFuture(null); + // If we have a cached proxy body (from 407 response), use it + if (cachedProxyBody != null) { + try { + // Use cached response and body + Response proxyResponse = cachedProxyResponse; + byte[] bodyBytes = cachedProxyBody; + cachedProxyResponse = null; + cachedProxyBody = null; + + HttpResponse.BodySubscriber subscriber = handler.apply( + new ResponseInfoImpl(proxyResponse)); + subscriber.onSubscribe(new java.util.concurrent.Flow.Subscription() { + public void request(long n) { + subscriber.onNext(java.util.List.of(java.nio.ByteBuffer.wrap(bodyBytes))); + subscriber.onComplete(); + } + public void cancel() {} + }); + return subscriber.getBody().toCompletableFuture(); + } catch (Exception e) { + return MinimalFuture.failedFuture(e); + } + } + // The connection will not be returned to the pool in the case of WebSocket return exchImpl.readBodyAsync(handler, !request.isWebSocket(), parentExecutor) .whenComplete((r,t) -> exchImpl.completed()); @@ -455,14 +482,20 @@ public CompletableFuture responseAsync() { private CompletableFuture checkFor407(ExchangeImpl ex, Throwable t, Function,CompletableFuture> andThen) { t = Utils.getCompletionCause(t); - if (t instanceof ProxyAuthenticationRequired) { + if (t instanceof ProxyAuthenticationRequired par) { if (debug.on()) debug.log("checkFor407: ProxyAuthenticationRequired: building synthetic response"); - bodyIgnored = MinimalFuture.completedFuture(null); - Response proxyResponse = ((ProxyAuthenticationRequired)t).proxyResponse; + // Cache the proxy response and body if available + cachedProxyResponse = par.proxyResponse; + cachedProxyBody = par.proxyResponseBody; + // Don't set bodyIgnored if we have a cached body + if (cachedProxyBody == null) { + bodyIgnored = MinimalFuture.completedFuture(null); + } HttpConnection c = ex == null ? null : ex.connection(); Response syntheticResponse = new Response(request, this, - proxyResponse.headers, c, proxyResponse.statusCode, - proxyResponse.version, true); + cachedProxyResponse.headers, c, cachedProxyResponse.statusCode, + cachedProxyResponse.version, cachedProxyBody == null); // body ignored only if not cached + return MinimalFuture.completedFuture(syntheticResponse); } else if (t != null) { if (debug.on()) debug.log("checkFor407: no response - %s", (Object)t); diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/PlainTunnelingConnection.java b/src/java.net.http/share/classes/jdk/internal/net/http/PlainTunnelingConnection.java index 147d5938fe5c9..318ab79a764c9 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/PlainTunnelingConnection.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/PlainTunnelingConnection.java @@ -36,13 +36,15 @@ import jdk.internal.net.http.common.FlowTube; import jdk.internal.net.http.common.MinimalFuture; + import static java.net.http.HttpResponse.BodyHandlers.discarding; +import static java.net.http.HttpResponse.BodyHandlers.ofByteArray; import static jdk.internal.net.http.common.Utils.ProxyHeaders; /** * A plain text socket tunnel through a proxy. Uses "CONNECT" but does not - * encrypt. Used by WebSocket, as well as HTTP over SSL + Proxy. - * Wrapped in SSLTunnelConnection or AsyncSSLTunnelConnection for encryption. + * encrypt. Used by WebSocket, as well as HTTP over SSL + Proxy. Wrapped in + * SSLTunnelConnection or AsyncSSLTunnelConnection for encryption. */ final class PlainTunnelingConnection extends HttpConnection { @@ -65,65 +67,84 @@ protected PlainTunnelingConnection(Origin originServer, @Override public CompletableFuture connectAsync(Exchange exchange) { - if (debug.on()) debug.log("Connecting plain connection"); + if (debug.on()) { + debug.log("Connecting plain connection"); + } return delegate.connectAsync(exchange) - .thenCompose(unused -> delegate.finishConnect()) - .thenCompose((Void v) -> { - if (debug.on()) debug.log("sending HTTP/1.1 CONNECT"); - HttpClientImpl client = client(); - assert client != null; - HttpRequestImpl req = new HttpRequestImpl("CONNECT", address, proxyHeaders); - MultiExchange mulEx = new MultiExchange<>(null, req, - client, discarding(), null); - Exchange connectExchange = mulEx.getExchange(); - - return connectExchange - .responseAsyncImpl(delegate) - .thenCompose((Response resp) -> { - CompletableFuture cf = new MinimalFuture<>(); - if (debug.on()) debug.log("got response: %d", resp.statusCode()); - if (resp.statusCode() == 407) { - return connectExchange.ignoreBody().handle((r,t) -> { - // close delegate after reading body: we won't - // be reusing that connection anyway. + .thenCompose(unused -> delegate.finishConnect()) + .thenCompose((Void v) -> { + if (debug.on()) { + debug.log("sending HTTP/1.1 CONNECT"); + } + HttpClientImpl client = client(); + assert client != null; + HttpRequestImpl req = new HttpRequestImpl( + "CONNECT", address, proxyHeaders); + MultiExchange mulEx = new MultiExchange<>( + null, req, client, ofByteArray(), null); + Exchange connectExchange = mulEx.getExchange(); + + return connectExchange + .responseAsyncImpl(delegate) + .thenCompose((Response resp) -> { + CompletableFuture cf = + new MinimalFuture<>(); + if (debug.on()) { + debug.log("got response: %d", + resp.statusCode()); + } + if (resp.statusCode() == 407) { + // Read the 407 body + return connectExchange.readBodyAsync(ofByteArray()) + .handle((bodyBytes, t) -> { + // close delegate after reading body: we won't + // be reusing that connection anyway. + delegate.close(); + ProxyAuthenticationRequired authenticationRequired = + new ProxyAuthenticationRequired(resp, bodyBytes); + cf.completeExceptionally(authenticationRequired); + return cf; + }).thenCompose(Function.identity()); + } else if (resp.statusCode() != 200) { delegate.close(); - ProxyAuthenticationRequired authenticationRequired = - new ProxyAuthenticationRequired(resp); - cf.completeExceptionally(authenticationRequired); - return cf; - }).thenCompose(Function.identity()); - } else if (resp.statusCode() != 200) { - delegate.close(); - cf.completeExceptionally(new IOException( - "Tunnel failed, got: "+ resp.statusCode())); - } else { - // get the initial/remaining bytes - ByteBuffer b = ((Http1Exchange)connectExchange.exchImpl).drainLeftOverBytes(); - int remaining = b.remaining(); - assert remaining == 0: "Unexpected remaining: " + remaining; - cf.complete(null); - } - return cf; - }) - .handle((result, ex) -> { - if (ex == null) { - return MinimalFuture.completedFuture(result); - } else { - if (debug.on()) - debug.log("tunnel failed with \"%s\"", ex.toString()); - Throwable t = ex; - if (t instanceof CompletionException) - t = t.getCause(); - if (t instanceof HttpTimeoutException) { - String msg = "proxy tunneling CONNECT request timed out"; - t = new HttpTimeoutException(msg); - t.initCause(ex); + cf.completeExceptionally(new IOException( + "Tunnel failed, got: " + + resp.statusCode())); + } else { + // get the initial/remaining bytes + ByteBuffer b = ((Http1Exchange) connectExchange.exchImpl) + .drainLeftOverBytes(); + int remaining = b.remaining(); + assert remaining == 0 + : "Unexpected remaining: " + remaining; + cf.complete(null); + } + return cf; + }) + .handle((result, ex) -> { + if (ex == null) { + return MinimalFuture.completedFuture( + result); + } else { + if (debug.on()) { + debug.log("tunnel failed with \"%s\"", + ex.toString()); + } + Throwable t = ex; + if (t instanceof CompletionException) { + t = t.getCause(); + } + if (t instanceof HttpTimeoutException) { + String msg = + "proxy tunneling CONNECT request timed out"; + t = new HttpTimeoutException(msg); + t.initCause(ex); + } + return MinimalFuture.failedFuture(t); } - return MinimalFuture.failedFuture(t); - } - }) - .thenCompose(Function.identity()); - }); + }) + .thenCompose(Function.identity()); + }); } public CompletableFuture finishConnect() { @@ -132,10 +153,14 @@ public CompletableFuture finishConnect() { } @Override - boolean isTunnel() { return true; } + boolean isTunnel() { + return true; + } @Override - HttpPublisher publisher() { return delegate.publisher(); } + HttpPublisher publisher() { + return delegate.publisher(); + } @Override boolean connected() { diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/ProxyAuthenticationRequired.java b/src/java.net.http/share/classes/jdk/internal/net/http/ProxyAuthenticationRequired.java index 72f5cc8635f18..933b43638061f 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/ProxyAuthenticationRequired.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/ProxyAuthenticationRequired.java @@ -34,16 +34,16 @@ final class ProxyAuthenticationRequired extends IOException { private static final long serialVersionUID = 0; final transient Response proxyResponse; + final transient byte[] proxyResponseBody; - /** - * Constructs a {@code ProxyAuthenticationRequired} with the specified detail - * message and cause. - * - * @param proxyResponse the response from the proxy - */ public ProxyAuthenticationRequired(Response proxyResponse) { + this(proxyResponse, null); + } + + public ProxyAuthenticationRequired(Response proxyResponse, byte[] proxyResponseBody) { super("Proxy Authentication Required"); assert proxyResponse.statusCode() == 407; this.proxyResponse = proxyResponse; + this.proxyResponseBody = proxyResponseBody; } } diff --git a/test/jdk/java/net/httpclient/ProxyAuthHttpTest.java b/test/jdk/java/net/httpclient/ProxyAuthHttpTest.java new file mode 100644 index 0000000000000..8f9345ed8f12c --- /dev/null +++ b/test/jdk/java/net/httpclient/ProxyAuthHttpTest.java @@ -0,0 +1,235 @@ +/* + * @test + * @bug 8328894 + * @summary HttpResponse.body() returns null with HTTPS target and failed proxy authentication + * @library /test/lib + * @run main/othervm ProxyAuthHttpTest + */ + +import java.io.*; +import java.net.*; +import java.net.http.*; +import java.util.concurrent.*; +import java.util.stream.*; +import com.sun.net.httpserver.HttpServer; + +/** + * This test reproduces JDK-8328894: + * When a proxy requires authentication, HTTPS requests receive a 407 response + * but HttpResponse.body() returns null, while HTTP requests return a proper body. + * + * This test verifies the fix by testing: + * 1. Basic HTTP and HTTPS 407 responses with body + * 2. Various BodyHandlers (ofString, ofByteArray, ofInputStream, ofLines) + * 3. Response headers (Content-Type, Content-Length, status code) + * 4. Body content correctness + */ +public class ProxyAuthHttpTest { + + static HttpServer targetServer; + static ServerSocket proxySocket; + static volatile boolean stop = false; + + static final String EXPECTED_BODY = "Proxy Authentication Required"; + static int testsPassed = 0; + static int testsFailed = 0; + + public static void main(String[] args) throws Exception { + // 1. Start dummy target HTTP server (never actually reached) + targetServer = HttpServer.create(new InetSocketAddress(0), 0); + targetServer.createContext("/", exchange -> { + byte[] body = "OK".getBytes(); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + targetServer.start(); + + // 2. Start simple proxy that always responds 407 + proxySocket = new ServerSocket(0); + Thread proxyThread = new Thread(ProxyAuthHttpTest::runProxy, "proxy-thread"); + proxyThread.setDaemon(true); + proxyThread.start(); + + int proxyPort = proxySocket.getLocalPort(); + System.out.println("Proxy running on port " + proxyPort); + + // 3. Build HttpClient using this proxy + InetSocketAddress proxyAddr = new InetSocketAddress("127.0.0.1", proxyPort); + HttpClient client = HttpClient.newBuilder() + .proxy(ProxySelector.of(proxyAddr)) + .build(); + + try { + // 4. Run comprehensive tests + System.out.println("\n=== Testing HTTP 407 responses ==="); + testBasicResponse(client, "http://example.invalid/"); + testBodyHandlerString(client, "http://example.invalid/"); + testBodyHandlerByteArray(client, "http://example.invalid/"); + testBodyHandlerInputStream(client, "http://example.invalid/"); + testBodyHandlerLines(client, "http://example.invalid/"); + testResponseHeaders(client, "http://example.invalid/"); + + System.out.println("\n=== Testing HTTPS 407 responses ==="); + testBasicResponse(client, "https://example.invalid/"); + testBodyHandlerString(client, "https://example.invalid/"); + testBodyHandlerByteArray(client, "https://example.invalid/"); + testBodyHandlerInputStream(client, "https://example.invalid/"); + testBodyHandlerLines(client, "https://example.invalid/"); + testResponseHeaders(client, "https://example.invalid/"); + + // 5. Print summary + System.out.println("\n=== Test Summary ==="); + System.out.println("Passed: " + testsPassed); + System.out.println("Failed: " + testsFailed); + + if (testsFailed > 0) { + throw new RuntimeException(testsFailed + " test(s) failed"); + } + } finally { + stop = true; + proxySocket.close(); + targetServer.stop(0); + } + } + + private static void runProxy() { + try { + while (!stop) { + Socket s = proxySocket.accept(); + new Thread(() -> handleProxyConnection(s)).start(); + } + } catch (IOException ignored) {} + } + + private static void handleProxyConnection(Socket s) { + try (Socket socket = s; + BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream())); + OutputStream out = socket.getOutputStream()) { + + String firstLine = in.readLine(); + if (firstLine == null) return; + + System.out.println("Proxy received: " + firstLine); + + String body = "Proxy Authentication Required"; + byte[] bytes = body.getBytes(); + + String resp = "HTTP/1.1 407 Proxy Authentication Required\r\n" + + "Content-Type: text/html; charset=utf-8\r\n" + + "Proxy-Authenticate: Basic realm=\"Proxy\"\r\n" + + "Content-Length: " + bytes.length + "\r\n" + + "\r\n"; + + out.write(resp.getBytes()); + out.write(bytes); + out.flush(); + } catch (IOException ignored) {} + } + + // Test helper + private static void test(String testName, boolean passed, String message) { + if (passed) { + System.out.println(" [PASS] " + testName); + testsPassed++; + } else { + System.out.println(" [FAIL] " + testName + ": " + message); + testsFailed++; + } + } + + // Test 1: Basic response body not null + private static void testBasicResponse(HttpClient client, String url) throws Exception { + System.out.println("\nTest: Basic response (" + url + ")"); + HttpRequest req = HttpRequest.newBuilder(URI.create(url)).GET().build(); + HttpResponse res = client.send(req, HttpResponse.BodyHandlers.ofString()); + + test("Status code is 407", res.statusCode() == 407, + "Expected 407, got " + res.statusCode()); + test("Body is not null", res.body() != null, + "Body is null"); + test("Body matches expected", EXPECTED_BODY.equals(res.body()), + "Expected '" + EXPECTED_BODY + "', got '" + res.body() + "'"); + } + + // Test 2: BodyHandlers.ofString() + private static void testBodyHandlerString(HttpClient client, String url) throws Exception { + System.out.println("\nTest: BodyHandler.ofString() (" + url + ")"); + HttpRequest req = HttpRequest.newBuilder(URI.create(url)).GET().build(); + HttpResponse res = client.send(req, HttpResponse.BodyHandlers.ofString()); + + test("Body is not null", res.body() != null, "Body is null"); + test("Body is String type", res.body() instanceof String, "Wrong type"); + test("Body content correct", EXPECTED_BODY.equals(res.body()), + "Content mismatch"); + } + + // Test 3: BodyHandlers.ofByteArray() + private static void testBodyHandlerByteArray(HttpClient client, String url) throws Exception { + System.out.println("\nTest: BodyHandler.ofByteArray() (" + url + ")"); + HttpRequest req = HttpRequest.newBuilder(URI.create(url)).GET().build(); + HttpResponse res = client.send(req, HttpResponse.BodyHandlers.ofByteArray()); + + test("Body is not null", res.body() != null, "Body is null"); + test("Body length correct", res.body().length == EXPECTED_BODY.length(), + "Expected length " + EXPECTED_BODY.length() + ", got " + res.body().length); + test("Body content correct", EXPECTED_BODY.equals(new String(res.body())), + "Content mismatch"); + } + + // Test 4: BodyHandlers.ofInputStream() + private static void testBodyHandlerInputStream(HttpClient client, String url) throws Exception { + System.out.println("\nTest: BodyHandler.ofInputStream() (" + url + ")"); + HttpRequest req = HttpRequest.newBuilder(URI.create(url)).GET().build(); + HttpResponse res = client.send(req, HttpResponse.BodyHandlers.ofInputStream()); + + test("Body is not null", res.body() != null, "Body is null"); + + try (InputStream is = res.body()) { + String content = new String(is.readAllBytes()); + test("Body content correct", EXPECTED_BODY.equals(content), + "Content mismatch"); + } + } + + // Test 5: BodyHandlers.ofLines() + private static void testBodyHandlerLines(HttpClient client, String url) throws Exception { + System.out.println("\nTest: BodyHandler.ofLines() (" + url + ")"); + HttpRequest req = HttpRequest.newBuilder(URI.create(url)).GET().build(); + HttpResponse> res = client.send(req, HttpResponse.BodyHandlers.ofLines()); + + test("Body is not null", res.body() != null, "Body is null"); + + String content = res.body().collect(Collectors.joining()); + test("Body content correct", EXPECTED_BODY.equals(content), + "Content mismatch"); + } + + // Test 6: Response headers + private static void testResponseHeaders(HttpClient client, String url) throws Exception { + System.out.println("\nTest: Response headers (" + url + ")"); + HttpRequest req = HttpRequest.newBuilder(URI.create(url)).GET().build(); + HttpResponse res = client.send(req, HttpResponse.BodyHandlers.ofString()); + + test("Status code is 407", res.statusCode() == 407, + "Expected 407, got " + res.statusCode()); + + var headers = res.headers(); + test("Has Content-Type header", + headers.firstValue("Content-Type").isPresent(), + "Content-Type header missing"); + test("Content-Type is text/html", + headers.firstValue("Content-Type").orElse("").contains("text/html"), + "Wrong Content-Type: " + headers.firstValue("Content-Type").orElse("")); + test("Has Content-Length header", + headers.firstValue("Content-Length").isPresent(), + "Content-Length header missing"); + test("Content-Length is correct", + headers.firstValue("Content-Length").orElse("").equals(String.valueOf(EXPECTED_BODY.length())), + "Wrong Content-Length: " + headers.firstValue("Content-Length").orElse("")); + test("Has Proxy-Authenticate header", + headers.firstValue("Proxy-Authenticate").isPresent(), + "Proxy-Authenticate header missing"); + } +}