From 65418a6a13c0c22bc66f0d560ec717ae9c7d652e Mon Sep 17 00:00:00 2001 From: mohammed adib Date: Sat, 4 Jul 2026 13:43:00 +0530 Subject: [PATCH 1/2] skip origin Authorization on CONNECT proxy request --- .../netty/request/NettyRequestFactory.java | 8 ++- .../ConnectRequestAuthorizationTest.java | 65 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 client/src/test/java/org/asynchttpclient/netty/request/ConnectRequestAuthorizationTest.java 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/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"); + } +} From a48b8f89eb2fb73913f60e424c16de7fa7782622 Mon Sep 17 00:00:00 2001 From: mohammed adib Date: Sat, 4 Jul 2026 16:05:52 +0530 Subject: [PATCH 2/2] skip origin Authorization on CONNECT in per-connection auth path NTLM/Kerberos/SPNEGO preemptive realms attach the origin Authorization in NettyRequestSender via perConnectionAuthorizationHeader, applied to future.getNettyRequest(), which is the CONNECT on the tunnel path. Gate it on the request method so those credentials stay off the plaintext CONNECT and only travel on the tunneled request. --- .../netty/request/NettyRequestSender.java | 11 +- .../ConnectRequestNtlmAuthorizationTest.java | 112 ++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 client/src/test/java/org/asynchttpclient/proxy/ConnectRequestNtlmAuthorizationTest.java 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/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); + } + } +}