diff --git a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java index 63fd7c0ce..b2f23ecbf 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestFactory.java @@ -306,8 +306,12 @@ public NettyRequest newNettyRequest(Request request, boolean performConnectReque headers.set(HOST, virtualHost != null ? virtualHost : hostHeader(uri)); } - // don't override authorization but append - addAuthorizationHeader(headers, perRequestAuthorizationHeader(request, realm)); + // don't override authorization but append. Skip it on a CONNECT: that request is sent to the + // proxy in the clear to open the tunnel, so the origin Authorization would be exposed to the + // proxy. It is added to the tunneled request, which is built separately once the tunnel is up. + if (!connect) { + addAuthorizationHeader(headers, perRequestAuthorizationHeader(request, realm)); + } // only set proxy auth on request over plain HTTP, or when performing CONNECT if (!uri.isSecured() || connect) { setProxyAuthorizationHeader(headers, perRequestProxyAuthorizationHeader(request, proxyRealm)); diff --git a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java index a92789fb7..94ad3e52b 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java @@ -401,7 +401,8 @@ private ListenableFuture sendRequestWithOpenChannel(NettyResponseFuture ListenableFuture sendRequestWithNewChannel(Request request, ProxyServer proxy, NettyResponseFuture future, AsyncHandler asyncHandler) { // some headers are only set when performing the first request - HttpHeaders headers = future.getNettyRequest().getHttpRequest().headers(); + HttpRequest nettyRequest = future.getNettyRequest().getHttpRequest(); + HttpHeaders headers = nettyRequest.headers(); if (proxy != null && proxy.getCustomHeaders() != null) { HttpHeaders customHeaders = proxy.getCustomHeaders().apply(request); if (customHeaders != null) { @@ -410,7 +411,13 @@ private ListenableFuture sendRequestWithNewChannel(Request request, Proxy } Realm realm = future.getRealm(); Realm proxyRealm = future.getProxyRealm(); - requestFactory.addAuthorizationHeader(headers, perConnectionAuthorizationHeader(request, proxy, realm)); + // On the tunnel path this is the CONNECT request, sent to the proxy in the clear before the TLS + // tunnel exists. Preemptive NTLM/Kerberos/SPNEGO realms attach their header here rather than in + // the factory, so skip it on CONNECT to keep the origin credentials off the plaintext hop. They + // travel on the tunneled request once the tunnel is up. + if (nettyRequest.method() != HttpMethod.CONNECT) { + requestFactory.addAuthorizationHeader(headers, perConnectionAuthorizationHeader(request, proxy, realm)); + } requestFactory.setProxyAuthorizationHeader(headers, perConnectionProxyAuthorizationHeader(request, proxyRealm)); future.setInAuth(realm != null && realm.isUsePreemptiveAuth() diff --git a/client/src/test/java/org/asynchttpclient/netty/request/ConnectRequestAuthorizationTest.java b/client/src/test/java/org/asynchttpclient/netty/request/ConnectRequestAuthorizationTest.java new file mode 100644 index 000000000..ab1ae1de0 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/netty/request/ConnectRequestAuthorizationTest.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * Licensed 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.asynchttpclient.netty.request; + +import io.netty.handler.codec.http.HttpHeaderNames; +import org.asynchttpclient.Realm; +import org.asynchttpclient.Request; +import org.asynchttpclient.proxy.ProxyServer; +import org.junit.jupiter.api.Test; + +import static org.asynchttpclient.Dsl.basicAuthRealm; +import static org.asynchttpclient.Dsl.config; +import static org.asynchttpclient.Dsl.get; +import static org.asynchttpclient.Dsl.proxyServer; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * A CONNECT request opens the proxy tunnel and is sent to the proxy in the clear, so it must not carry the + * origin {@code Authorization} header (which would expose the origin credentials to the proxy). The header + * belongs only on the request sent through the established tunnel. + */ +public class ConnectRequestAuthorizationTest { + + private static NettyRequestFactory factory() { + return new NettyRequestFactory(config().build()); + } + + @Test + public void connectRequestDoesNotCarryOriginAuthorization() { + Request request = get("https://origin.example.com/resource").build(); + Realm realm = basicAuthRealm("user", "secret").setUsePreemptiveAuth(true).build(); + ProxyServer proxy = proxyServer("proxy.example.com", 8080).build(); + + NettyRequest connect = factory().newNettyRequest(request, true, proxy, realm, null); + + assertFalse(connect.getHttpRequest().headers().contains(HttpHeaderNames.AUTHORIZATION), + "CONNECT request must not expose the origin Authorization to the proxy"); + } + + @Test + public void tunneledRequestKeepsOriginAuthorization() { + Request request = get("https://origin.example.com/resource").build(); + Realm realm = basicAuthRealm("user", "secret").setUsePreemptiveAuth(true).build(); + ProxyServer proxy = proxyServer("proxy.example.com", 8080).build(); + + NettyRequest tunneled = factory().newNettyRequest(request, false, proxy, realm, null); + + assertTrue(tunneled.getHttpRequest().headers().contains(HttpHeaderNames.AUTHORIZATION), + "the request sent through the tunnel must still carry the origin Authorization"); + } +} diff --git a/client/src/test/java/org/asynchttpclient/proxy/ConnectRequestNtlmAuthorizationTest.java b/client/src/test/java/org/asynchttpclient/proxy/ConnectRequestNtlmAuthorizationTest.java new file mode 100644 index 000000000..253c25e6d --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/proxy/ConnectRequestNtlmAuthorizationTest.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved. + * + * Licensed 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.asynchttpclient.proxy; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.RequestBuilder; +import org.asynchttpclient.Response; +import org.asynchttpclient.test.EchoHandler; +import org.asynchttpclient.util.HttpConstants; +import org.eclipse.jetty.proxy.ConnectHandler; +import org.eclipse.jetty.server.Handler; +import org.eclipse.jetty.server.Request; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.asynchttpclient.Dsl.asyncHttpClient; +import static org.asynchttpclient.Dsl.config; +import static org.asynchttpclient.Dsl.get; +import static org.asynchttpclient.Dsl.ntlmAuthRealm; +import static org.asynchttpclient.Dsl.proxyServer; +import static org.asynchttpclient.test.TestUtils.addHttpConnector; +import static org.asynchttpclient.test.TestUtils.addHttpsConnector; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * NTLM (like Kerberos and SPNEGO) uses {@code perConnectionAuthorizationHeader} in {@code NettyRequestSender} + * rather than the per-request path in {@code NettyRequestFactory}. That header must not be attached to the + * CONNECT request, which is sent to the proxy in the clear before the tunnel exists. + */ +public class ConnectRequestNtlmAuthorizationTest { + + private final List servers = new ArrayList<>(); + private int httpsPort; + private int proxyPort; + private final AtomicReference connectAuthorization = new AtomicReference<>(); + + private int startServer(Handler handler, boolean secure) throws Exception { + Server server = new Server(); + ServerConnector connector = secure ? addHttpsConnector(server) : addHttpConnector(server); + server.setHandler(handler); + server.start(); + servers.add(server); + return connector.getLocalPort(); + } + + @BeforeEach + public void setUp() throws Exception { + httpsPort = startServer(new EchoHandler(), true); + proxyPort = startServer(new RecordingConnectHandler(), false); + } + + @AfterEach + public void tearDown() { + servers.forEach(server -> { + try { + server.stop(); + } catch (Exception ignored) { + // couldn't stop server + } + }); + } + + @Test + public void connectRequestDoesNotCarryPreemptiveNtlmAuthorization() throws Exception { + try (AsyncHttpClient client = asyncHttpClient(config().setUseInsecureTrustManager(true))) { + RequestBuilder rb = get("https://localhost:" + httpsPort + "/foo/test") + .setProxyServer(proxyServer("localhost", proxyPort)) + .setRealm(ntlmAuthRealm("user", "secret").setUsePreemptiveAuth(true)); + + Response response = client.executeRequest(rb.build()).get(); + assertEquals(200, response.getStatusCode()); + assertNull(connectAuthorization.get(), + "CONNECT request must not expose the origin NTLM Authorization to the proxy"); + } + } + + private class RecordingConnectHandler extends ConnectHandler { + @Override + public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + if (HttpConstants.Methods.CONNECT.equalsIgnoreCase(request.getMethod())) { + connectAuthorization.set(request.getHeader("Authorization")); + } + super.handle(target, baseRequest, request, response); + } + } +}