diff --git a/rt/transports/http-hc5/src/main/java/org/apache/cxf/transport/http/asyncclient/hc5/AsyncHTTPConduit.java b/rt/transports/http-hc5/src/main/java/org/apache/cxf/transport/http/asyncclient/hc5/AsyncHTTPConduit.java index 92a68a2956d..0c899514fc4 100644 --- a/rt/transports/http-hc5/src/main/java/org/apache/cxf/transport/http/asyncclient/hc5/AsyncHTTPConduit.java +++ b/rt/transports/http-hc5/src/main/java/org/apache/cxf/transport/http/asyncclient/hc5/AsyncHTTPConduit.java @@ -63,6 +63,7 @@ import org.apache.cxf.message.MessageUtils; import org.apache.cxf.service.model.EndpointInfo; import org.apache.cxf.transport.http.Address; +import org.apache.cxf.transport.http.HTTPException; import org.apache.cxf.transport.http.Headers; import org.apache.cxf.transport.http.HttpClientHTTPConduit; import org.apache.cxf.transport.http.asyncclient.hc5.AsyncHTTPConduitFactory.UseAsyncPolicy; @@ -300,6 +301,7 @@ public class AsyncWrappedOutputStream extends WrappedOutputStream private Object sessionLock = new Object(); private boolean closed; + private HttpClientContext ctx; public AsyncWrappedOutputStream(Message message, boolean needToCacheRequest, boolean isChunking, int chunkThreshold, String conduitName, URI uri) { @@ -531,7 +533,7 @@ public void cancelled() { entity.setEntity(null); } - HttpClientContext ctx = HttpClientContext.create(); + ctx = HttpClientContext.create(); BasicCredentialsProvider credsProvider = new BasicCredentialsProvider() { @Override @@ -673,7 +675,12 @@ protected synchronized HttpResponse getHttpResponse() throws IOException { while (httpResponse == null) { if (exception == null) { //already have an exception, skip waiting try { - wait(); + long timeout = csPolicy.getReceiveTimeout(); + if (timeout > 0) { + wait(timeout); + } else { + wait(); + } } catch (InterruptedException e) { throw new IOException(e); } @@ -686,12 +693,34 @@ protected synchronized HttpResponse getHttpResponse() throws IOException { if (exception instanceof IOException) { throw (IOException)exception; } else if (exception instanceof RuntimeException) { - throw (RuntimeException)exception; + // HC5 async calls failed() with RuntimeException + // when proxy auth is exhausted, not 407 via + // consumeResponse(). Check context for 407. + if (ctx != null && ctx.getResponse() != null + && ctx.getResponse().getCode() + == HttpURLConnection.HTTP_PROXY_AUTH) { + throw new HTTPException( + HttpURLConnection.HTTP_PROXY_AUTH, + ctx.getResponse().getReasonPhrase(), + url.toURL()); + } + throw new IOException(exception); } - + throw new IOException(exception); } + // Timed out waiting for HC5 callback; check if proxy + // returned 407 during its internal auth handling. + if (ctx != null && ctx.getResponse() != null + && ctx.getResponse().getCode() + == HttpURLConnection.HTTP_PROXY_AUTH) { + throw new HTTPException( + HttpURLConnection.HTTP_PROXY_AUTH, + ctx.getResponse().getReasonPhrase(), + url.toURL()); + } + throw new SocketTimeoutException("Read Timeout"); } } diff --git a/systests/transport-hc5/src/test/java/org/apache/cxf/systest/hc5/http/auth/ProxyAuthIllegalStateTest.java b/systests/transport-hc5/src/test/java/org/apache/cxf/systest/hc5/http/auth/ProxyAuthIllegalStateTest.java new file mode 100644 index 00000000000..a42104ba96b --- /dev/null +++ b/systests/transport-hc5/src/test/java/org/apache/cxf/systest/hc5/http/auth/ProxyAuthIllegalStateTest.java @@ -0,0 +1,197 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.cxf.systest.hc5.http.auth; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +import javax.xml.namespace.QName; + +import jakarta.xml.ws.BindingProvider; +import jakarta.xml.ws.WebServiceException; +import org.apache.cxf.configuration.security.ProxyAuthorizationPolicy; +import org.apache.cxf.frontend.ClientProxy; +import org.apache.cxf.testutil.common.AbstractBusClientServerTestBase; +import org.apache.cxf.transport.http.HTTPConduit; +import org.apache.cxf.transport.http.HTTPException; +import org.apache.cxf.transport.http.asyncclient.hc5.AsyncHTTPConduit; +import org.apache.cxf.transports.http.configuration.HTTPClientPolicy; +import org.apache.hello_world.Greeter; +import org.apache.hello_world.services.SOAPService; + +import org.junit.AfterClass; +import org.junit.Assume; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; + +/** + * Reproducer for CXF-9226 (connection-close + retransmit variant): async + * HC5 conduit throws IllegalStateException instead of HTTPException(407) + * when the proxy returns 407, closes the TCP connection (Connection: close), + * and the client has MaxRetransmits/AutoRedirect configured (as in the JIRA). + * + *

With MaxRetransmits > 0 or AutoRedirect=true, CXF caches the request + * body so HC5 marks the entity as repeatable. HC5 then retries after the + * first 407, reconnects to the proxy, and sends a second request with the + * (wrong) Basic credentials. While HC5 is reconnecting, a concurrent + * write-failure on the closed socket calls discardEndpoint() which sets + * endpointRef to null. When the retry then calls execute() → + * ensureValid(), it finds endpointRef==null and throws + * IllegalStateException("Endpoint not acquired / already released"). + * + *

Without the CXF-9226 fix that ISE surfaces to the caller as an opaque + * IOException; with the fix CXF checks the 407 stored in HttpClientContext + * and throws HTTPException(407) instead. + */ +public class ProxyAuthIllegalStateTest + extends AbstractBusClientServerTestBase { + + static final String PROXY_PORT = + allocatePort(ProxyAuthIllegalStateTest.class); + + private static final QName SERVICE_NAME = + new QName("http://apache.org/hello_world", "SOAPService"); + private static final QName PORT_NAME = + new QName("http://apache.org/hello_world", "Mortimer"); + + private static ServerSocket proxySocket; + + @BeforeClass + public static void setup() throws Exception { + proxySocket = new ServerSocket(Integer.parseInt(PROXY_PORT)); + final ServerSocket socket = proxySocket; + Thread t = new Thread(() -> { + while (!socket.isClosed()) { + try { + drainAndReject(socket.accept()); + } catch (IOException e) { + // socket closed; exit loop + } + } + }); + t.setDaemon(true); + t.start(); + createStaticBus(); + } + + private static void drainAndReject(Socket client) throws IOException { + try (Socket s = client) { + InputStream in = s.getInputStream(); + // Slide a 4-byte window; stop at \r\n\r\n (end of headers) + int[] w = new int[]{-1, -1, -1, -1}; + int b; + while ((b = in.read()) != -1) { + System.arraycopy(w, 1, w, 0, 3); + w[3] = b; + if (w[0] == '\r' && w[1] == '\n' + && w[2] == '\r' && w[3] == '\n') { + break; + } + } + OutputStream out = s.getOutputStream(); + out.write(("HTTP/1.1 407 Proxy Authentication Required\r\n" + + "Proxy-Authenticate: Basic realm=\"test\"\r\n" + + "Content-Length: 0\r\n" + + "Connection: close\r\n" + + "\r\n").getBytes(StandardCharsets.US_ASCII)); + out.flush(); + } + } + + @AfterClass + public static void teardown() { + if (proxySocket != null) { + try { + proxySocket.close(); + } catch (IOException e) { + // ignore + } + proxySocket = null; + } + } + + /** + * CXF-9226: wrong proxy credentials must produce HTTPException(407), + * not IllegalStateException, when the proxy closes the connection and + * MaxRetransmits/AutoRedirect are configured (exact JIRA reproduction). + */ + @Test(timeout = 30_000) + public void testProxyAuthFailsWithHTTPExceptionNotIllegalState() + throws Exception { + Assume.assumeFalse("Skipped in forceURLConnection mode", + Boolean.getBoolean( + "org.apache.cxf.transport.http.forceURLConnection")); + URL wsdl = getClass().getResource("../greeting.wsdl"); + assertNotNull("WSDL not found", wsdl); + + SOAPService service = new SOAPService(wsdl, SERVICE_NAME); + Greeter greeter = service.getPort(PORT_NAME, Greeter.class); + assertNotNull("Port is null", greeter); + + BindingProvider bp = (BindingProvider) greeter; + bp.getRequestContext().put(BindingProvider.ENDPOINT_ADDRESS_PROPERTY, + "http://localhost:1/greeter"); + bp.getRequestContext().put(AsyncHTTPConduit.USE_ASYNC, Boolean.TRUE); + + HTTPConduit conduit = (HTTPConduit) + ClientProxy.getClient(greeter).getConduit(); + HTTPClientPolicy policy = new HTTPClientPolicy(); + policy.setConnectionTimeout(10_000L); + policy.setReceiveTimeout(10_000L); + policy.setProxyServer("localhost"); + policy.setProxyServerPort(Integer.parseInt(PROXY_PORT)); + // Match the exact JIRA configuration: MaxRetransmits and AutoRedirect + // force CXF to cache the request body, which makes the entity + // repeatable. HC5 then retries after the 407, and the concurrent + // write-failure on the Connection: close socket can set endpointRef=null + // before the retry's execute() call, triggering IllegalStateException. + policy.setMaxRetransmits(5); + policy.setAutoRedirect(true); + conduit.setClient(policy); + + ProxyAuthorizationPolicy proxyAuth = new ProxyAuthorizationPolicy(); + proxyAuth.setUserName("unknown-user"); + proxyAuth.setPassword("wrong-password"); + proxyAuth.setAuthorizationType("Basic"); + conduit.setProxyAuthorization(proxyAuth); + + try { + greeter.sayHi(); + fail("Expected exception for invalid proxy credentials"); + } catch (WebServiceException e) { + Throwable cause = e.getCause(); + assertNotNull("Expected a cause on the WebServiceException", cause); + if (!(cause instanceof HTTPException)) { + fail("Expected HTTPException(407) but got " + + cause.getClass().getName() + ": " + cause.getMessage()); + } + assertEquals("Expected 407 Proxy Authentication Required", + 407, ((HTTPException) cause).getResponseCode()); + } + } +}