From 14ee30acf476d52831f7048bf861a4752bb13a08 Mon Sep 17 00:00:00 2001 From: sullis Date: Wed, 2 Apr 2025 12:58:01 -0700 Subject: [PATCH 01/23] netty leak detector 0.0.8 (#2087) https://github.com/nettyplus/netty-leak-detector-junit-extension --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 70d09ac53..4dbe02c1d 100644 --- a/pom.xml +++ b/pom.xml @@ -112,7 +112,7 @@ io.github.nettyplus netty-leak-detector-junit-extension - 0.0.6 + 0.0.8 From 73911ebe4c464588fb10c211a43caeec394d97ca Mon Sep 17 00:00:00 2001 From: Pratik Katti <90851204+pratt4@users.noreply.github.com> Date: Fri, 9 May 2025 23:14:39 +0530 Subject: [PATCH 02/23] Fix NPE race in NettyResponseFuture.cancel (#2042) (#2088) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2042 This is a typical TOCTOU (time-of-check/time-of-use) race https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use. The NPE was occurring because the channel field could be set to null by another thread between the check and its use: if (channel != null) { // time-of-check Channels.setDiscard(channel); // time-of-use Channels.silentlyCloseChannel(channel); } By copying channel into a local variable in one atomic read, we ensure that—even if another thread changes the field—the local reference remains valid. P.S. It is hard to write a deterministic test that fails consistently, so this PR only includes the code fix. --------- Co-authored-by: prat --- .../org/asynchttpclient/netty/NettyResponseFuture.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java b/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java index c5e4a97d0..c29c0f33d 100755 --- a/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java +++ b/client/src/main/java/org/asynchttpclient/netty/NettyResponseFuture.java @@ -187,10 +187,10 @@ public boolean cancel(boolean force) { return false; } - // cancel could happen before channel was attached - if (channel != null) { - Channels.setDiscard(channel); - Channels.silentlyCloseChannel(channel); + final Channel ch = channel; //atomic read, so that it won't end up in TOCTOU + if (ch != null) { + Channels.setDiscard(ch); + Channels.silentlyCloseChannel(ch); } if (ON_THROWABLE_CALLED_FIELD.getAndSet(this, 1) == 0) { From 6ac1cccad93bf617200f6a87f9790de273529256 Mon Sep 17 00:00:00 2001 From: Aayush Atharva Date: Sun, 11 May 2025 04:58:54 +0530 Subject: [PATCH 03/23] Add japicmp (#2091) --- .github/workflows/builds.yml | 26 +++++++++++++++++++++----- pom.xml | 28 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/.github/workflows/builds.yml b/.github/workflows/builds.yml index 6a59bde6c..2586cf3c6 100644 --- a/.github/workflows/builds.yml +++ b/.github/workflows/builds.yml @@ -5,34 +5,50 @@ on: - cron: '0 12 * * *' jobs: + Verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Grant Permission + run: chmod +x ./mvnw + - uses: actions/setup-java@v4 + with: + distribution: 'corretto' + java-version: '11' + - name: Verify + run: ./mvnw -B -ntp clean verify -DskipTests -Dgpg.skip=true + RunOnLinux: runs-on: ubuntu-latest + needs: Verify steps: - uses: actions/checkout@v4 - name: Grant Permission - run: sudo chmod +x ./mvnw + run: chmod +x ./mvnw - uses: actions/setup-java@v4 with: distribution: 'corretto' java-version: '11' - name: Run Tests - run: ./mvnw -B -ntp clean test + run: ./mvnw -B -ntp test RunOnMacOs: runs-on: macos-latest + needs: Verify steps: - uses: actions/checkout@v4 - name: Grant Permission - run: sudo chmod +x ./mvnw + run: chmod +x ./mvnw - uses: actions/setup-java@v4 with: distribution: 'corretto' java-version: '11' - name: Run Tests - run: ./mvnw -B -ntp clean test + run: ./mvnw -B -ntp test RunOnWindows: runs-on: windows-latest + needs: Verify steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 @@ -40,4 +56,4 @@ jobs: distribution: 'corretto' java-version: '11' - name: Run Tests - run: ./mvnw.cmd -B -ntp clean test + run: ./mvnw.cmd -B -ntp test diff --git a/pom.xml b/pom.xml index 4dbe02c1d..ee1c2308c 100644 --- a/pom.xml +++ b/pom.xml @@ -422,10 +422,38 @@ --pinentry-mode loopback + false + + + com.github.siom79.japicmp + japicmp-maven-plugin + 0.23.1 + + + RELEASE + ${project.version} + + + true + true + true + false + public + + + + + + cmp + + verify + + + From fb50dc26717f0e6aaaef58e2a01924a56aab2021 Mon Sep 17 00:00:00 2001 From: Aayush Atharva Date: Sun, 11 May 2025 05:00:47 +0530 Subject: [PATCH 04/23] Feature: Add Option to Strip Authorization Header on Redirect (#2090) Closes #1884 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../AsyncHttpClientConfig.java | 7 ++ .../DefaultAsyncHttpClientConfig.java | 16 ++++ .../intercept/Redirect30xInterceptor.java | 9 +- .../DefaultAsyncHttpClientConfigTest.java | 30 ++++++ .../StripAuthorizationOnRedirectHttpTest.java | 95 +++++++++++++++++++ 5 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientConfigTest.java create mode 100644 client/src/test/java/org/asynchttpclient/StripAuthorizationOnRedirectHttpTest.java diff --git a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java index 12dc93d7d..954628b3d 100644 --- a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java @@ -375,6 +375,13 @@ public interface AsyncHttpClientConfig { int getIoThreadsCount(); + /** + * Indicates whether the Authorization header should be stripped during redirects to a different domain. + * + * @return true if the Authorization header should be stripped, false otherwise. + */ + boolean isStripAuthorizationOnRedirect(); + enum ResponseBodyPartFactory { EAGER { diff --git a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java index e72235c17..1c7dbf37f 100644 --- a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java @@ -127,6 +127,7 @@ public class DefaultAsyncHttpClientConfig implements AsyncHttpClientConfig { private final boolean keepEncodingHeader; private final ProxyServerSelector proxyServerSelector; private final boolean validateResponseHeaders; + private final boolean stripAuthorizationOnRedirect; // websockets private final boolean aggregateWebSocketFrameFragments; @@ -219,6 +220,7 @@ private DefaultAsyncHttpClientConfig(// http boolean validateResponseHeaders, boolean aggregateWebSocketFrameFragments, boolean enablewebSocketCompression, + boolean stripAuthorizationOnRedirect, // timeouts Duration connectTimeout, @@ -307,6 +309,7 @@ private DefaultAsyncHttpClientConfig(// http this.keepEncodingHeader = keepEncodingHeader; this.proxyServerSelector = proxyServerSelector; this.validateResponseHeaders = validateResponseHeaders; + this.stripAuthorizationOnRedirect = stripAuthorizationOnRedirect; // websocket this.aggregateWebSocketFrameFragments = aggregateWebSocketFrameFragments; @@ -564,6 +567,11 @@ public boolean isValidateResponseHeaders() { return validateResponseHeaders; } + @Override + public boolean isStripAuthorizationOnRedirect() { + return stripAuthorizationOnRedirect; + } + // ssl @Override public boolean isUseOpenSsl() { @@ -800,6 +808,7 @@ public static class Builder { private boolean useProxySelector = defaultUseProxySelector(); private boolean useProxyProperties = defaultUseProxyProperties(); private boolean validateResponseHeaders = defaultValidateResponseHeaders(); + private boolean stripAuthorizationOnRedirect = false; // default value // websocket private boolean aggregateWebSocketFrameFragments = defaultAggregateWebSocketFrameFragments(); @@ -891,6 +900,7 @@ public Builder(AsyncHttpClientConfig config) { keepEncodingHeader = config.isKeepEncodingHeader(); proxyServerSelector = config.getProxyServerSelector(); validateResponseHeaders = config.isValidateResponseHeaders(); + stripAuthorizationOnRedirect = config.isStripAuthorizationOnRedirect(); // websocket aggregateWebSocketFrameFragments = config.isAggregateWebSocketFrameFragments(); @@ -1079,6 +1089,11 @@ public Builder setUseProxyProperties(boolean useProxyProperties) { return this; } + public Builder setStripAuthorizationOnRedirect(boolean value) { + stripAuthorizationOnRedirect = value; + return this; + } + // websocket public Builder setAggregateWebSocketFrameFragments(boolean aggregateWebSocketFrameFragments) { this.aggregateWebSocketFrameFragments = aggregateWebSocketFrameFragments; @@ -1444,6 +1459,7 @@ public DefaultAsyncHttpClientConfig build() { validateResponseHeaders, aggregateWebSocketFrameFragments, enablewebSocketCompression, + stripAuthorizationOnRedirect, connectTimeout, requestTimeout, readTimeout, diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java index e60495f80..40628a7e5 100644 --- a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptor.java @@ -35,7 +35,6 @@ import org.slf4j.LoggerFactory; import java.util.HashSet; -import java.util.List; import java.util.Set; import static io.netty.handler.codec.http.HttpHeaderNames.AUTHORIZATION; @@ -73,11 +72,13 @@ public class Redirect30xInterceptor { private final AsyncHttpClientConfig config; private final NettyRequestSender requestSender; private final MaxRedirectException maxRedirectException; + private final boolean stripAuthorizationOnRedirect; Redirect30xInterceptor(ChannelManager channelManager, AsyncHttpClientConfig config, NettyRequestSender requestSender) { this.channelManager = channelManager; this.config = config; this.requestSender = requestSender; + stripAuthorizationOnRedirect = config.isStripAuthorizationOnRedirect(); // New flag maxRedirectException = unknownStackTrace(new MaxRedirectException("Maximum redirect reached: " + config.getMaxRedirects()), Redirect30xInterceptor.class, "exitAfterHandlingRedirect"); } @@ -127,7 +128,7 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture } } - requestBuilder.setHeaders(propagatedHeaders(request, realm, keepBody)); + requestBuilder.setHeaders(propagatedHeaders(request, realm, keepBody, stripAuthorizationOnRedirect)); // in case of a redirect from HTTP to HTTPS, future // attributes might change @@ -180,7 +181,7 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture return false; } - private static HttpHeaders propagatedHeaders(Request request, Realm realm, boolean keepBody) { + private static HttpHeaders propagatedHeaders(Request request, Realm realm, boolean keepBody, boolean stripAuthorization) { HttpHeaders headers = request.getHeaders() .remove(HOST) .remove(CONTENT_LENGTH); @@ -189,7 +190,7 @@ private static HttpHeaders propagatedHeaders(Request request, Realm realm, boole headers.remove(CONTENT_TYPE); } - if (realm != null && realm.getScheme() == AuthScheme.NTLM) { + if (stripAuthorization || (realm != null && realm.getScheme() == AuthScheme.NTLM)) { headers.remove(AUTHORIZATION) .remove(PROXY_AUTHORIZATION); } diff --git a/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientConfigTest.java b/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientConfigTest.java new file mode 100644 index 000000000..1548d6812 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientConfigTest.java @@ -0,0 +1,30 @@ +package org.asynchttpclient; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DefaultAsyncHttpClientConfigTest { + @Test + void testStripAuthorizationOnRedirect_DefaultIsFalse() { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder().build(); + assertFalse(config.isStripAuthorizationOnRedirect(), "Default should be false"); + } + + @Test + void testStripAuthorizationOnRedirect_SetTrue() { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() + .setStripAuthorizationOnRedirect(true) + .build(); + assertTrue(config.isStripAuthorizationOnRedirect(), "Should be true when set"); + } + + @Test + void testStripAuthorizationOnRedirect_SetFalse() { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() + .setStripAuthorizationOnRedirect(false) + .build(); + assertFalse(config.isStripAuthorizationOnRedirect(), "Should be false when set to false"); + } +} diff --git a/client/src/test/java/org/asynchttpclient/StripAuthorizationOnRedirectHttpTest.java b/client/src/test/java/org/asynchttpclient/StripAuthorizationOnRedirectHttpTest.java new file mode 100644 index 000000000..08c150c08 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/StripAuthorizationOnRedirectHttpTest.java @@ -0,0 +1,95 @@ +package org.asynchttpclient; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class StripAuthorizationOnRedirectHttpTest { + private static HttpServer server; + private static int port; + private static volatile String lastAuthHeader; + + @BeforeAll + public static void startServer() throws Exception { + server = HttpServer.create(new InetSocketAddress(0), 0); + port = server.getAddress().getPort(); + server.createContext("/redirect", new RedirectHandler()); + server.createContext("/final", new FinalHandler()); + server.start(); + } + + @AfterAll + public static void stopServer() { + server.stop(0); + } + + static class RedirectHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) { + String auth = exchange.getRequestHeaders().getFirst("Authorization"); + lastAuthHeader = auth; + exchange.getResponseHeaders().add("Location", "http://localhost:" + port + "/final"); + try { + exchange.sendResponseHeaders(302, -1); + } catch (Exception ignored) { + } + exchange.close(); + } + } + + static class FinalHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) { + String auth = exchange.getRequestHeaders().getFirst("Authorization"); + lastAuthHeader = auth; + try { + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().close(); + } catch (Exception ignored) { + } + exchange.close(); + } + } + + @Test + void testAuthHeaderPropagatedByDefault() throws Exception { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() + .setFollowRedirect(true) + .build(); + try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) { + lastAuthHeader = null; + client.prepareGet("http://localhost:" + port + "/redirect") + .setHeader("Authorization", "Bearer testtoken") + .execute() + .get(5, TimeUnit.SECONDS); + // By default, Authorization header is propagated to /final + assertEquals("Bearer testtoken", lastAuthHeader, "Authorization header should be present on redirect by default"); + } + } + + @Test + void testAuthHeaderStrippedWhenEnabled() throws Exception { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() + .setFollowRedirect(true) + .setStripAuthorizationOnRedirect(true) + .build(); + try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) { + lastAuthHeader = null; + client.prepareGet("http://localhost:" + port + "/redirect") + .setHeader("Authorization", "Bearer testtoken") + .execute() + .get(5, TimeUnit.SECONDS); + // When enabled, Authorization header should be stripped on /final + assertNull(lastAuthHeader, "Authorization header should be stripped on redirect when enabled"); + } + } +} From 41b1eec767ded1c2dcf9e7c690a4b8b6e0145e83 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 18 May 2025 22:24:59 +0530 Subject: [PATCH 05/23] Bump org.apache.tomcat.embed:tomcat-embed-core from 10.1.39 to 10.1.40 in /client (#2092) Bumps org.apache.tomcat.embed:tomcat-embed-core from 10.1.39 to 10.1.40. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.apache.tomcat.embed:tomcat-embed-core&package-manager=maven&previous-version=10.1.39&new-version=10.1.40)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/AsyncHttpClient/async-http-client/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- client/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pom.xml b/client/pom.xml index 749a98ddb..733f20b51 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -31,7 +31,7 @@ org.asynchttpclient.client 11.0.24 - 10.1.39 + 10.1.40 2.18.0 4.11.0 3.0 From c8cc6e82e633e4f5d8e71646a9432e6e1d5b41a3 Mon Sep 17 00:00:00 2001 From: sullis Date: Thu, 22 May 2025 12:50:25 -0700 Subject: [PATCH 06/23] netty leak detector extension 0.2.0 (#2095) https://github.com/nettyplus/netty-leak-detector-junit-extension --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index ee1c2308c..e55fe8a26 100644 --- a/pom.xml +++ b/pom.xml @@ -112,7 +112,7 @@ io.github.nettyplus netty-leak-detector-junit-extension - 0.0.8 + 0.2.0 From e1431755d76df9137abfdb8f97c883b41e1ae913 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 May 2025 09:22:38 +0530 Subject: [PATCH 07/23] Bump org.apache.tomcat.embed:tomcat-embed-core from 10.1.40 to 10.1.41 in /client (#2096) Bumps org.apache.tomcat.embed:tomcat-embed-core from 10.1.40 to 10.1.41. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.apache.tomcat.embed:tomcat-embed-core&package-manager=maven&previous-version=10.1.40&new-version=10.1.41)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/AsyncHttpClient/async-http-client/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- client/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pom.xml b/client/pom.xml index 733f20b51..93d36ec02 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -31,7 +31,7 @@ org.asynchttpclient.client 11.0.24 - 10.1.40 + 10.1.41 2.18.0 4.11.0 3.0 From eeec3349d367ee0409acc5a63cc8d25f568e5539 Mon Sep 17 00:00:00 2001 From: sullis Date: Fri, 30 May 2025 12:49:27 -0700 Subject: [PATCH 08/23] junit 5.13.0 (#2097) --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e55fe8a26..9d64fc54b 100644 --- a/pom.xml +++ b/pom.xml @@ -105,7 +105,7 @@ org.junit junit-bom - 5.11.4 + 5.13.0 pom import From b2a0440e1164665ca506897f3577ade9cc681b52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 00:38:27 +0530 Subject: [PATCH 09/23] Bump org.apache.tomcat.embed:tomcat-embed-core from 10.1.41 to 10.1.42 in /client (#2099) Bumps org.apache.tomcat.embed:tomcat-embed-core from 10.1.41 to 10.1.42. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.apache.tomcat.embed:tomcat-embed-core&package-manager=maven&previous-version=10.1.41&new-version=10.1.42)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/AsyncHttpClient/async-http-client/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- client/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pom.xml b/client/pom.xml index 93d36ec02..ff11d5f26 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -31,7 +31,7 @@ org.asynchttpclient.client 11.0.24 - 10.1.41 + 10.1.42 2.18.0 4.11.0 3.0 From d7330d20e27e2d63f1055dfcc1040e0bbcf96a51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 20 Jul 2025 00:10:00 +0530 Subject: [PATCH 10/23] Bump commons-fileupload:commons-fileupload from 1.5 to 1.6.0 in /client (#2101) Bumps commons-fileupload:commons-fileupload from 1.5 to 1.6.0. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=commons-fileupload:commons-fileupload&package-manager=maven&previous-version=1.5&new-version=1.6.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/AsyncHttpClient/async-http-client/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- client/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pom.xml b/client/pom.xml index ff11d5f26..6e56bf9fc 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -51,7 +51,7 @@ commons-fileupload commons-fileupload - 1.5 + 1.6.0 test From 8daef69da541970c9c8365e1413d13d127e17118 Mon Sep 17 00:00:00 2001 From: hnb22 Date: Thu, 14 Aug 2025 15:08:18 -0700 Subject: [PATCH 11/23] closed parenthesis addition - toString() in ChannelPoolPartitioning (#2103) --- .../asynchttpclient/channel/ChannelPoolPartitioning.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/channel/ChannelPoolPartitioning.java b/client/src/main/java/org/asynchttpclient/channel/ChannelPoolPartitioning.java index 324a4ce34..c91ed6bda 100644 --- a/client/src/main/java/org/asynchttpclient/channel/ChannelPoolPartitioning.java +++ b/client/src/main/java/org/asynchttpclient/channel/ChannelPoolPartitioning.java @@ -15,13 +15,13 @@ */ package org.asynchttpclient.channel; +import java.util.Objects; + import org.asynchttpclient.proxy.ProxyServer; import org.asynchttpclient.proxy.ProxyType; import org.asynchttpclient.uri.Uri; import org.jetbrains.annotations.Nullable; -import java.util.Objects; - @FunctionalInterface public interface ChannelPoolPartitioning { @@ -111,7 +111,8 @@ public String toString() { ", virtualHost=" + virtualHost + ", proxyHost=" + proxyHost + ", proxyPort=" + proxyPort + - ", proxyType=" + proxyType; + ", proxyType=" + proxyType + + ")"; } } } From d2c780da34ef1a3f28fd8a5ba00ba8f591272c4c Mon Sep 17 00:00:00 2001 From: Chris Vest Date: Fri, 22 Aug 2025 20:25:34 -0700 Subject: [PATCH 12/23] Future-proof HTTPS endpoint identification (#2104) Netty 4.2 changes the default for hostname verification for TLS clients, so that it is now enabled by default. As a result, clients that rely on the default being _off_ will find themselves unable to disable it. Instead, clients should explicitly configure their desired endpoint identification algorithm in all cases. Since Netty 4.1.112 we also have a convenient method on the `SslContextBuilder` for doing this, so we don't need multiple round-trips through `SSLParameters`. This PR changes the `DefaultSslEngineFactory` to make use of this method, so it always configures the endpoint identification algorithm to match the desired setting of `AsyncHttpClientConfig..isDisableHttpsEndpointIdentificationAlgorithm()`. --- .../asynchttpclient/netty/ssl/DefaultSslEngineFactory.java | 3 +++ .../org/asynchttpclient/netty/ssl/SslEngineFactoryBase.java | 6 ------ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/netty/ssl/DefaultSslEngineFactory.java b/client/src/main/java/org/asynchttpclient/netty/ssl/DefaultSslEngineFactory.java index a96f6ffb1..323b75d5d 100644 --- a/client/src/main/java/org/asynchttpclient/netty/ssl/DefaultSslEngineFactory.java +++ b/client/src/main/java/org/asynchttpclient/netty/ssl/DefaultSslEngineFactory.java @@ -58,6 +58,9 @@ private SslContext buildSslContext(AsyncHttpClientConfig config) throws SSLExcep sslContextBuilder.trustManager(InsecureTrustManagerFactory.INSTANCE); } + sslContextBuilder.endpointIdentificationAlgorithm( + config.isDisableHttpsEndpointIdentificationAlgorithm() ? "" : "HTTPS"); + return configureSslContextBuilder(sslContextBuilder).build(); } diff --git a/client/src/main/java/org/asynchttpclient/netty/ssl/SslEngineFactoryBase.java b/client/src/main/java/org/asynchttpclient/netty/ssl/SslEngineFactoryBase.java index 2d6e5f5ef..7e55ac4de 100644 --- a/client/src/main/java/org/asynchttpclient/netty/ssl/SslEngineFactoryBase.java +++ b/client/src/main/java/org/asynchttpclient/netty/ssl/SslEngineFactoryBase.java @@ -19,7 +19,6 @@ import org.asynchttpclient.SslEngineFactory; import javax.net.ssl.SSLEngine; -import javax.net.ssl.SSLParameters; public abstract class SslEngineFactoryBase implements SslEngineFactory { @@ -30,10 +29,5 @@ protected String domain(String hostname) { protected void configureSslEngine(SSLEngine sslEngine, AsyncHttpClientConfig config) { sslEngine.setUseClientMode(true); - if (!config.isDisableHttpsEndpointIdentificationAlgorithm()) { - SSLParameters params = sslEngine.getSSLParameters(); - params.setEndpointIdentificationAlgorithm("HTTPS"); - sslEngine.setSSLParameters(params); - } } } From 8e2551139743a41153b66a344c6cc0d81abb619c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 23 Aug 2025 08:56:09 +0530 Subject: [PATCH 13/23] Bump org.apache.tomcat.embed:tomcat-embed-core from 10.1.42 to 10.1.44 in /client (#2105) Bumps org.apache.tomcat.embed:tomcat-embed-core from 10.1.42 to 10.1.44. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.apache.tomcat.embed:tomcat-embed-core&package-manager=maven&previous-version=10.1.42&new-version=10.1.44)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/AsyncHttpClient/async-http-client/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- client/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pom.xml b/client/pom.xml index 6e56bf9fc..596f38feb 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -31,7 +31,7 @@ org.asynchttpclient.client 11.0.24 - 10.1.42 + 10.1.44 2.18.0 4.11.0 3.0 From 8f7e24925f390b3e249cb1ac45012259b04f647c Mon Sep 17 00:00:00 2001 From: FranWell Date: Wed, 27 Aug 2025 01:41:32 +0800 Subject: [PATCH 14/23] Fix incorrect logger class in ResumableAsyncHandler (#2107) --- .../handler/resumable/ResumableAsyncHandler.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/handler/resumable/ResumableAsyncHandler.java b/client/src/main/java/org/asynchttpclient/handler/resumable/ResumableAsyncHandler.java index 6b8794547..55a5fba14 100644 --- a/client/src/main/java/org/asynchttpclient/handler/resumable/ResumableAsyncHandler.java +++ b/client/src/main/java/org/asynchttpclient/handler/resumable/ResumableAsyncHandler.java @@ -20,7 +20,6 @@ import org.asynchttpclient.RequestBuilder; import org.asynchttpclient.Response; import org.asynchttpclient.Response.ResponseBuilder; -import org.asynchttpclient.handler.TransferCompletionHandler; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,7 +46,7 @@ * Beware that it registers a shutdown hook, that will cause a ClassLoader leak when used in an appserver and only redeploying the application. */ public class ResumableAsyncHandler implements AsyncHandler { - private static final Logger logger = LoggerFactory.getLogger(TransferCompletionHandler.class); + private static final Logger LOGGER = LoggerFactory.getLogger(ResumableAsyncHandler.class); private static final ResumableIndexThread resumeIndexThread = new ResumableIndexThread(); private static Map resumableIndex = Collections.emptyMap(); @@ -125,7 +124,7 @@ public void onThrowable(Throwable t) { if (decoratedAsyncHandler != null) { decoratedAsyncHandler.onThrowable(t); } else { - logger.debug("", t); + LOGGER.debug("", t); } } From 420474ccb35ffc08029c1713f54d687688933181 Mon Sep 17 00:00:00 2001 From: Aayush Atharva Date: Mon, 1 Sep 2025 02:25:21 +0530 Subject: [PATCH 15/23] HTTPS Proxy Support (#2109) Motivation: AHC only supports HTTP proxy at the moment, not HTTPS. HTTPS is required in many environments because CONNECT has to be encrypted to prevent eavesdropping. Modification: Added HTTPS proxy support. Fixes: #1907 --- .gitignore | 4 + client/pom.xml | 83 ++++++ .../channel/ChannelPoolPartitioning.java | 2 +- .../netty/channel/ChannelManager.java | 68 ++++- .../netty/channel/NettyConnectListener.java | 52 +++- .../intercept/ConnectSuccessInterceptor.java | 14 +- .../netty/request/NettyRequestSender.java | 3 +- .../org/asynchttpclient/proxy/ProxyType.java | 2 +- .../proxy/HttpsProxyBasicTest.java | 115 ++++++++ .../proxy/HttpsProxyIntegrationTest.java | 261 ++++++++++++++++++ .../asynchttpclient/proxy/HttpsProxyTest.java | 188 ++++++++++--- ...tpsProxyTestcontainersIntegrationTest.java | 191 +++++++++++++ client/src/test/resources/squid/Dockerfile | 26 ++ client/src/test/resources/squid/squid.conf | 19 ++ pom.xml | 1 + 15 files changed, 976 insertions(+), 53 deletions(-) create mode 100644 client/src/test/java/org/asynchttpclient/proxy/HttpsProxyBasicTest.java create mode 100644 client/src/test/java/org/asynchttpclient/proxy/HttpsProxyIntegrationTest.java create mode 100644 client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java create mode 100644 client/src/test/resources/squid/Dockerfile create mode 100644 client/src/test/resources/squid/squid.conf diff --git a/.gitignore b/.gitignore index d424b2597..546e0e6fd 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ MANIFEST.MF work atlassian-ide-plugin.xml /bom/.flattened-pom.xml + +# Docker volumes and logs (but keep configuration) +docker/squid/logs/ +docker/nginx/logs/ diff --git a/client/pom.xml b/client/pom.xml index 596f38feb..9c0cefee3 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -188,5 +188,88 @@ 2.1.6 test + + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + + docker-tests + + + docker.tests + true + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + false + true + + + + + + + + testcontainers-auto + + true + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + + + + + + + + + no-docker-tests + + + no.docker.tests + true + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + disabled + + + + + + + diff --git a/client/src/main/java/org/asynchttpclient/channel/ChannelPoolPartitioning.java b/client/src/main/java/org/asynchttpclient/channel/ChannelPoolPartitioning.java index c91ed6bda..291d81844 100644 --- a/client/src/main/java/org/asynchttpclient/channel/ChannelPoolPartitioning.java +++ b/client/src/main/java/org/asynchttpclient/channel/ChannelPoolPartitioning.java @@ -50,7 +50,7 @@ public Object getPartitionKey(Uri uri, @Nullable String virtualHost, @Nullable P targetHostBaseUrl, virtualHost, proxyServer.getHost(), - uri.isSecured() && proxyServer.getProxyType() == ProxyType.HTTP ? + uri.isSecured() && proxyServer.getProxyType().isHttp() ? proxyServer.getSecuredPort() : proxyServer.getPort(), proxyServer.getProxyType()); diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java index c5c94c551..fc55d453d 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java @@ -67,6 +67,7 @@ import org.asynchttpclient.netty.request.NettyRequestSender; import org.asynchttpclient.netty.ssl.DefaultSslEngineFactory; import org.asynchttpclient.proxy.ProxyServer; +import org.asynchttpclient.proxy.ProxyType; import org.asynchttpclient.uri.Uri; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -386,14 +387,68 @@ public Future updatePipelineForHttpTunneling(ChannelPipeline pipeline, } if (requestUri.isSecured()) { - if (!isSslHandlerConfigured(pipeline)) { - SslHandler sslHandler = createSslHandler(requestUri.getHost(), requestUri.getExplicitPort()); - whenHandshaked = sslHandler.handshakeFuture(); - pipeline.addBefore(INFLATER_HANDLER, SSL_HANDLER, sslHandler); + // For HTTPS targets, we always need to add/replace the SSL handler for the target connection + // even if there's already an SSL handler in the pipeline (which would be for an HTTPS proxy) + if (isSslHandlerConfigured(pipeline)) { + // Remove existing SSL handler (for proxy) and replace with SSL handler for target + pipeline.remove(SSL_HANDLER); } + SslHandler sslHandler = createSslHandler(requestUri.getHost(), requestUri.getExplicitPort()); + whenHandshaked = sslHandler.handshakeFuture(); + pipeline.addBefore(INFLATER_HANDLER, SSL_HANDLER, sslHandler); pipeline.addAfter(SSL_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec()); } else { + // For HTTP targets, remove any existing SSL handler (from HTTPS proxy) since target is not secured + if (isSslHandlerConfigured(pipeline)) { + pipeline.remove(SSL_HANDLER); + } + pipeline.addBefore(AHC_HTTP_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec()); + } + + if (requestUri.isWebSocket()) { + pipeline.addAfter(AHC_HTTP_HANDLER, AHC_WS_HANDLER, wsHandler); + + if (config.isEnableWebSocketCompression()) { + pipeline.addBefore(AHC_WS_HANDLER, WS_COMPRESSOR_HANDLER, WebSocketClientCompressionHandler.INSTANCE); + } + + pipeline.remove(AHC_HTTP_HANDLER); + } + return whenHandshaked; + } + + public Future updatePipelineForHttpsTunneling(ChannelPipeline pipeline, Uri requestUri, ProxyServer proxyServer) { + Future whenHandshaked = null; + + // Remove HTTP codec as tunnel is established + if (pipeline.get(HTTP_CLIENT_CODEC) != null) { + pipeline.remove(HTTP_CLIENT_CODEC); + } + + if (requestUri.isSecured()) { + // For HTTPS proxy to HTTPS target, we need to establish target SSL over the proxy SSL tunnel + // The proxy SSL handler should remain as it provides the tunnel transport + // We need to add target SSL handler that will negotiate with the target through the tunnel + + SslHandler sslHandler = createSslHandler(requestUri.getHost(), requestUri.getExplicitPort()); + whenHandshaked = sslHandler.handshakeFuture(); + + // For HTTPS proxy tunnel, add target SSL handler after the existing proxy SSL handler + // This creates a nested SSL setup: Target SSL -> Proxy SSL -> Network + if (isSslHandlerConfigured(pipeline)) { + // Insert target SSL handler after the proxy SSL handler + pipeline.addAfter(SSL_HANDLER, "target-ssl", sslHandler); + } else { + // This shouldn't happen for HTTPS proxy, but fallback + pipeline.addBefore(INFLATER_HANDLER, SSL_HANDLER, sslHandler); + } + + pipeline.addAfter("target-ssl", HTTP_CLIENT_CODEC, newHttpClientCodec()); + + } else { + // For HTTPS proxy to HTTP target, just add HTTP codec + // The proxy SSL handler provides the tunnel and remains pipeline.addBefore(AHC_HTTP_HANDLER, HTTP_CLIENT_CODEC, newHttpClientCodec()); } @@ -406,6 +461,7 @@ public Future updatePipelineForHttpTunneling(ChannelPipeline pipeline, pipeline.remove(AHC_HTTP_HANDLER); } + return whenHandshaked; } @@ -486,6 +542,10 @@ protected void initChannel(Channel channel) throws Exception { } }); + } else if (proxy != null && ProxyType.HTTPS.equals(proxy.getProxyType())) { + // For HTTPS proxies, use HTTP bootstrap but ensure SSL connection to proxy + // The SSL handler for connecting to the proxy will be added in the connect phase + promise.setSuccess(httpBootstrap); } else { promise.setSuccess(httpBootstrap); } diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java b/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java index 719733f8a..2b6a840f5 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java @@ -26,6 +26,7 @@ import org.asynchttpclient.netty.request.NettyRequestSender; import org.asynchttpclient.netty.timeout.TimeoutsHolder; import org.asynchttpclient.proxy.ProxyServer; +import org.asynchttpclient.proxy.ProxyType; import org.asynchttpclient.uri.Uri; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -100,8 +101,57 @@ public void onSuccess(Channel channel, InetSocketAddress remoteAddress) { timeoutsHolder.setResolvedRemoteAddress(remoteAddress); ProxyServer proxyServer = future.getProxyServer(); + // For HTTPS proxies, establish SSL connection to the proxy server first + if (proxyServer != null && ProxyType.HTTPS.equals(proxyServer.getProxyType())) { + SslHandler sslHandler; + try { + sslHandler = channelManager.addSslHandler(channel.pipeline(), + Uri.create("https://" + proxyServer.getHost() + ":" + proxyServer.getSecuredPort()), + null, false); + } catch (Exception sslError) { + onFailure(channel, sslError); + return; + } + + final AsyncHandler asyncHandler = future.getAsyncHandler(); + + try { + asyncHandler.onTlsHandshakeAttempt(); + } catch (Exception e) { + LOGGER.error("onTlsHandshakeAttempt crashed", e); + onFailure(channel, e); + return; + } + + sslHandler.handshakeFuture().addListener(new SimpleFutureListener() { + @Override + protected void onSuccess(Channel value) { + try { + asyncHandler.onTlsHandshakeSuccess(sslHandler.engine().getSession()); + } catch (Exception e) { + LOGGER.error("onTlsHandshakeSuccess crashed", e); + NettyConnectListener.this.onFailure(channel, e); + return; + } + // After SSL handshake to proxy, continue with normal proxy request + writeRequest(channel); + } + + @Override + protected void onFailure(Throwable cause) { + try { + asyncHandler.onTlsHandshakeFailure(cause); + } catch (Exception e) { + LOGGER.error("onTlsHandshakeFailure crashed", e); + NettyConnectListener.this.onFailure(channel, e); + return; + } + NettyConnectListener.this.onFailure(channel, cause); + } + }); + // in case of proxy tunneling, we'll add the SslHandler later, after the CONNECT request - if ((proxyServer == null || proxyServer.getProxyType().isSocks()) && uri.isSecured()) { + } else if ((proxyServer == null || proxyServer.getProxyType().isSocks()) && uri.isSecured()) { SslHandler sslHandler; try { sslHandler = channelManager.addSslHandler(channel.pipeline(), uri, request.getVirtualHost(), proxyServer != null); diff --git a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ConnectSuccessInterceptor.java b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ConnectSuccessInterceptor.java index 22e29dbfb..bf64e5909 100644 --- a/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ConnectSuccessInterceptor.java +++ b/client/src/main/java/org/asynchttpclient/netty/handler/intercept/ConnectSuccessInterceptor.java @@ -22,6 +22,7 @@ import org.asynchttpclient.netty.channel.ChannelManager; import org.asynchttpclient.netty.request.NettyRequestSender; import org.asynchttpclient.proxy.ProxyServer; +import org.asynchttpclient.proxy.ProxyType; import org.asynchttpclient.uri.Uri; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,7 +46,18 @@ public boolean exitAfterHandlingConnect(Channel channel, NettyResponseFuture Uri requestUri = request.getUri(); LOGGER.debug("Connecting to proxy {} for scheme {}", proxyServer, requestUri.getScheme()); - final Future whenHandshaked = channelManager.updatePipelineForHttpTunneling(channel.pipeline(), requestUri); + + final Future whenHandshaked; + + // Special handling for HTTPS proxy tunneling + if (proxyServer != null && ProxyType.HTTPS.equals(proxyServer.getProxyType())) { + // For HTTPS proxy, we need special tunnel pipeline management + whenHandshaked = channelManager.updatePipelineForHttpsTunneling(channel.pipeline(), requestUri, proxyServer); + } else { + // Standard HTTP proxy or SOCKS proxy tunneling + whenHandshaked = channelManager.updatePipelineForHttpTunneling(channel.pipeline(), requestUri); + } + future.setReuseChannel(true); future.setConnectAllowed(false); 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 b66dd713d..c929d35e2 100755 --- a/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java +++ b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java @@ -54,6 +54,7 @@ import org.asynchttpclient.netty.channel.NettyConnectListener; import org.asynchttpclient.netty.timeout.TimeoutsHolder; import org.asynchttpclient.proxy.ProxyServer; +import org.asynchttpclient.proxy.ProxyType; import org.asynchttpclient.resolver.RequestHostnameResolver; import org.asynchttpclient.uri.Uri; import org.asynchttpclient.ws.WebSocketUpgradeHandler; @@ -337,7 +338,7 @@ private Future> resolveAddresses(Request request, Pr final Promise> promise = ImmediateEventExecutor.INSTANCE.newPromise(); if (proxy != null && !proxy.isIgnoredForHost(uri.getHost()) && proxy.getProxyType().isHttp()) { - int port = uri.isSecured() ? proxy.getSecuredPort() : proxy.getPort(); + int port = ProxyType.HTTPS.equals(proxy.getProxyType()) || uri.isSecured() ? proxy.getSecuredPort() : proxy.getPort(); InetSocketAddress unresolvedRemoteAddress = InetSocketAddress.createUnresolved(proxy.getHost(), port); scheduleRequestTimeout(future, unresolvedRemoteAddress); return RequestHostnameResolver.INSTANCE.resolve(request.getNameResolver(), unresolvedRemoteAddress, asyncHandler); diff --git a/client/src/main/java/org/asynchttpclient/proxy/ProxyType.java b/client/src/main/java/org/asynchttpclient/proxy/ProxyType.java index d1f74e70d..0963eda8c 100644 --- a/client/src/main/java/org/asynchttpclient/proxy/ProxyType.java +++ b/client/src/main/java/org/asynchttpclient/proxy/ProxyType.java @@ -16,7 +16,7 @@ package org.asynchttpclient.proxy; public enum ProxyType { - HTTP(true), SOCKS_V4(false), SOCKS_V5(false); + HTTP(true), HTTPS(true), SOCKS_V4(false), SOCKS_V5(false); private final boolean http; diff --git a/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyBasicTest.java b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyBasicTest.java new file mode 100644 index 000000000..29876708e --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyBasicTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 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 io.github.artsok.RepeatedIfExceptionsTest; +import org.asynchttpclient.channel.ChannelPoolPartitioning; +import org.asynchttpclient.uri.Uri; + +import static org.asynchttpclient.Dsl.proxyServer; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Basic tests for HTTPS proxy type functionality without network calls. + */ +public class HttpsProxyBasicTest { + + @RepeatedIfExceptionsTest(repeats = 5) + public void testHttpsProxyTypeConfiguration() throws Exception { + // Test that HTTPS proxy type can be configured correctly + ProxyServer.Builder builder = proxyServer("proxy.example.com", 8080) + .setSecuredPort(8443) + .setProxyType(ProxyType.HTTPS); + + ProxyServer proxy = builder.build(); + + assertEquals(ProxyType.HTTPS, proxy.getProxyType()); + assertEquals(true, proxy.getProxyType().isHttp()); + assertEquals(8443, proxy.getSecuredPort()); + assertEquals(8080, proxy.getPort()); + assertEquals("proxy.example.com", proxy.getHost()); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testHttpsProxyTypeDefaultSecuredPort() { + // Test HTTPS proxy type with default secured port + ProxyServer proxy = proxyServer("proxy.example.com", 8080) + .setProxyType(ProxyType.HTTPS) + .build(); + + assertEquals(ProxyType.HTTPS, proxy.getProxyType()); + assertEquals(true, proxy.getProxyType().isHttp()); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testChannelPoolPartitioningWithHttpsProxy() { + // Test that HTTPS proxy creates correct partition keys for connection pooling + ProxyServer httpsProxy = proxyServer("proxy.example.com", 8080) + .setSecuredPort(8443) + .setProxyType(ProxyType.HTTPS) + .build(); + + Uri targetUri = Uri.create("https://target.example.com/test"); + ChannelPoolPartitioning partitioning = ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE; + + Object partitionKey = partitioning.getPartitionKey(targetUri, null, httpsProxy); + + assertNotNull(partitionKey); + // The partition key should include the secured port for HTTPS proxy with HTTPS target + assertTrue(partitionKey.toString().contains("8443")); + assertTrue(partitionKey.toString().contains("HTTPS")); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testChannelPoolPartitioningHttpsProxyHttpTarget() { + // Test HTTPS proxy with HTTP target - should use normal port + ProxyServer httpsProxy = proxyServer("proxy.example.com", 8080) + .setSecuredPort(8443) + .setProxyType(ProxyType.HTTPS) + .build(); + + Uri targetUri = Uri.create("http://target.example.com/test"); + ChannelPoolPartitioning partitioning = ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE; + + Object partitionKey = partitioning.getPartitionKey(targetUri, null, httpsProxy); + + assertNotNull(partitionKey); + // For HTTP target, should use normal proxy port + assertTrue(partitionKey.toString().contains("8080")); + assertTrue(partitionKey.toString().contains("HTTPS")); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testChannelPoolPartitioningWithHttpProxy() { + // Test that HTTP proxy creates correct partition keys for connection pooling + ProxyServer httpProxy = proxyServer("proxy.example.com", 8080) + .setSecuredPort(8443) + .setProxyType(ProxyType.HTTP) + .build(); + + Uri targetUri = Uri.create("https://target.example.com/test"); + ChannelPoolPartitioning partitioning = ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE; + + Object partitionKey = partitioning.getPartitionKey(targetUri, null, httpProxy); + + assertNotNull(partitionKey); + // For HTTP proxy with secured target, should use secured port + assertTrue(partitionKey.toString().contains("8443")); + assertTrue(partitionKey.toString().contains("HTTP")); + } +} diff --git a/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyIntegrationTest.java b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyIntegrationTest.java new file mode 100644 index 000000000..ef4614ba1 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyIntegrationTest.java @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2025 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 io.github.artsok.RepeatedIfExceptionsTest; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.asynchttpclient.AbstractBasicTest; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.AsyncHttpClientConfig; +import org.asynchttpclient.RequestBuilder; +import org.asynchttpclient.Response; +import org.asynchttpclient.channel.ChannelPoolPartitioning; +import org.asynchttpclient.request.body.generator.ByteArrayBodyGenerator; +import org.asynchttpclient.test.EchoHandler; +import org.asynchttpclient.uri.Uri; +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.eclipse.jetty.server.handler.AbstractHandler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import static org.asynchttpclient.Dsl.asyncHttpClient; +import static org.asynchttpclient.Dsl.config; +import static org.asynchttpclient.Dsl.get; +import static org.asynchttpclient.Dsl.post; +import static org.asynchttpclient.Dsl.proxyServer; +import static org.asynchttpclient.test.TestUtils.LARGE_IMAGE_BYTES; +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Comprehensive integration tests for HTTPS proxy functionality. + * Tests both HTTP and HTTPS proxy types to ensure functionality and compatibility. + */ +public class HttpsProxyIntegrationTest extends AbstractBasicTest { + + private List servers; + private int httpsProxyPort; + + @Override + public AbstractHandler configureHandler() throws Exception { + return new ProxyHandler(); + } + + /** + * Provides test parameters for HTTP proxy type only for now + * TODO: Add HTTPS proxy type once SSL bootstrap is implemented + */ + static Stream proxyTypeProvider() { + return Stream.of( + Arguments.of("HTTP Proxy", ProxyType.HTTP) + // Arguments.of("HTTPS Proxy", ProxyType.HTTPS) // TODO: Enable once HTTPS proxy SSL bootstrap is working + ); + } + + @Override + @BeforeEach + public void setUpGlobal() throws Exception { + servers = new ArrayList<>(); + + // Start HTTP proxy server + port1 = startServer(configureHandler(), false); + + // Start HTTPS target server + port2 = startServer(new EchoHandler(), true); + + // Start HTTPS proxy server + httpsProxyPort = startServer(configureHandler(), true); + + logger.info("Integration test servers started: HTTP proxy={}, HTTPS proxy={}, HTTPS target={}", + port1, httpsProxyPort, port2); + } + + private int startServer(Handler handler, boolean secure) throws Exception { + Server server = new Server(); + @SuppressWarnings("resource") + ServerConnector connector = secure ? addHttpsConnector(server) : addHttpConnector(server); + server.setHandler(handler); + server.start(); + servers.add(server); + return connector.getLocalPort(); + } + + @Override + @AfterEach + public void tearDownGlobal() { + servers.forEach(server -> { + try { + server.stop(); + } catch (Exception e) { + // couldn't stop server + } + }); + } + + @ParameterizedTest(name = "{0} - Basic Request") + @MethodSource("proxyTypeProvider") + public void testBasicRequestThroughProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : port1; + + try (AsyncHttpClient client = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true))) { + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServer("localhost", proxyPort).setProxyType(proxyType)); + Response response = client.executeRequest(rb.build()).get(); + assertEquals(200, response.getStatusCode()); + + // Verify that the request went through the proxy + assertNotNull(response); + } + } + + @ParameterizedTest(name = "{0} - Multiple Requests") + @MethodSource("proxyTypeProvider") + public void testMultipleRequestsThroughProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : port1; + + try (AsyncHttpClient client = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true).setKeepAlive(true))) { + ProxyServer proxy = proxyServer("localhost", proxyPort).setProxyType(proxyType).build(); + + // Execute multiple requests to test connection reuse + for (int i = 0; i < 3; i++) { + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxy); + Response response = client.executeRequest(rb.build()).get(); + assertEquals(200, response.getStatusCode(), "Request " + (i + 1) + " failed"); + } + } + } + + @ParameterizedTest(name = "{0} - Large Body") + @MethodSource("proxyTypeProvider") + public void testLargeRequestBodyThroughProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : port1; + + try (AsyncHttpClient client = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true))) { + ProxyServer proxy = proxyServer("localhost", proxyPort).setProxyType(proxyType).build(); + + RequestBuilder rb = post(getTargetUrl2()) + .setProxyServer(proxy) + .setBody(new ByteArrayBodyGenerator(LARGE_IMAGE_BYTES)); + + Response response = client.executeRequest(rb.build()).get(); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().length() > 0); + } + } + + @ParameterizedTest(name = "{0} - Timeout Configuration") + @MethodSource("proxyTypeProvider") + public void testProxyTimeoutConfiguration(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : port1; + + AsyncHttpClientConfig config = config() + .setFollowRedirect(true) + .setUseInsecureTrustManager(true) + .setConnectTimeout(Duration.ofSeconds(5)) + .setRequestTimeout(Duration.ofSeconds(10)) + .build(); + + try (AsyncHttpClient client = asyncHttpClient(config)) { + ProxyServer proxy = proxyServer("localhost", proxyPort).setProxyType(proxyType).build(); + + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxy); + Response response = client.executeRequest(rb.build()).get(15, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testChannelPoolPartitioningWithHttpsProxy() throws Exception { + // Test that HTTPS proxy creates correct partition keys for connection pooling + ProxyServer httpsProxy = proxyServer("proxy.example.com", 8080) + .setSecuredPort(8443) + .setProxyType(ProxyType.HTTPS) + .build(); + + Uri targetUri = Uri.create("https://target.example.com/test"); + ChannelPoolPartitioning partitioning = ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE; + + Object partitionKey = partitioning.getPartitionKey(targetUri, null, httpsProxy); + + assertNotNull(partitionKey); + // The partition key should include the secured port for HTTPS proxy + assertTrue(partitionKey.toString().contains("8443")); + assertTrue(partitionKey.toString().contains("HTTPS")); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testChannelPoolPartitioningWithHttpProxy() throws Exception { + // Test that HTTP proxy creates correct partition keys for connection pooling + ProxyServer httpProxy = proxyServer("proxy.example.com", 8080) + .setSecuredPort(8443) + .setProxyType(ProxyType.HTTP) + .build(); + + Uri targetUri = Uri.create("https://target.example.com/test"); + ChannelPoolPartitioning partitioning = ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE; + + Object partitionKey = partitioning.getPartitionKey(targetUri, null, httpProxy); + + assertNotNull(partitionKey); + // For HTTP proxy with secured target, should use secured port + assertTrue(partitionKey.toString().contains("8443")); + assertTrue(partitionKey.toString().contains("HTTP")); + } + + public static class ProxyHandler extends ConnectHandler { + final static String HEADER_FORBIDDEN = "X-REJECT-REQUEST"; + + @Override + public void handle(String s, Request r, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { + if (HttpConstants.Methods.CONNECT.equalsIgnoreCase(request.getMethod())) { + String headerValue = request.getHeader(HEADER_FORBIDDEN); + if (headerValue == null) { + headerValue = ""; + } + switch (headerValue) { + case "1": + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + r.setHandled(true); + return; + case "2": + r.getHttpChannel().getConnection().close(); + r.setHandled(true); + return; + } + } + super.handle(s, r, request, response); + } + } +} diff --git a/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTest.java b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTest.java index 9bd5ca911..a6d4b6985 100644 --- a/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTest.java +++ b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTest.java @@ -17,7 +17,6 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; - import org.asynchttpclient.AbstractBasicTest; import org.asynchttpclient.AsyncHttpClient; import org.asynchttpclient.AsyncHttpClientConfig; @@ -28,12 +27,22 @@ 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.eclipse.jetty.server.handler.AbstractHandler; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.stream.Stream; import static org.asynchttpclient.Dsl.asyncHttpClient; import static org.asynchttpclient.Dsl.config; @@ -46,60 +55,93 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrowsExactly; -import java.io.IOException; -import java.util.concurrent.ExecutionException; - /** * Proxy usage tests. */ public class HttpsProxyTest extends AbstractBasicTest { - private Server server2; + private List servers; + private int proxyPort; + private int httpsProxyPort; @Override public AbstractHandler configureHandler() throws Exception { return new ProxyHandler(); } + /** + * Provides test parameters for HTTP proxy type working, HTTPS proxy tests added but with known SSL bootstrap issue + */ + static Stream proxyTypeProvider() { + return Stream.of( + Arguments.of("HTTP Proxy", ProxyType.HTTP) + // Note: HTTPS proxy tests will be enabled once SSL bootstrap implementation is completed + // Arguments.of("HTTPS Proxy", ProxyType.HTTPS) + ); + } + @Override @BeforeEach public void setUpGlobal() throws Exception { - server = new Server(); - ServerConnector connector = addHttpConnector(server); - server.setHandler(configureHandler()); - server.start(); - port1 = connector.getLocalPort(); + servers = new ArrayList<>(); + + // Start HTTP target server + port1 = startServer(new EchoHandler(), false); + + // Start HTTPS target server + port2 = startServer(new EchoHandler(), true); + + // Start HTTP proxy server + proxyPort = startServer(configureHandler(), false); + + // Start HTTPS proxy server + httpsProxyPort = startServer(configureHandler(), true); - server2 = new Server(); - ServerConnector connector2 = addHttpsConnector(server2); - server2.setHandler(new EchoHandler()); - server2.start(); - port2 = connector2.getLocalPort(); + logger.info("Local servers started successfully"); + } - logger.info("Local HTTP server started successfully"); + private int startServer(Handler handler, boolean secure) throws Exception { + Server server = new Server(); + @SuppressWarnings("resource") + ServerConnector connector = secure ? addHttpsConnector(server) : addHttpConnector(server); + server.setHandler(handler); + server.start(); + servers.add(server); + return connector.getLocalPort(); } @Override @AfterEach - public void tearDownGlobal() throws Exception { - server.stop(); - server2.stop(); + public void tearDownGlobal() { + servers.forEach(server -> { + try { + server.stop(); + } catch (Exception e) { + // couldn't stop server + } + }); } - @RepeatedIfExceptionsTest(repeats = 5) - public void testRequestProxy() throws Exception { + @ParameterizedTest(name = "{0}") + @MethodSource("proxyTypeProvider") + public void testRequestProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : this.proxyPort; + try (AsyncHttpClient client = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true))) { - RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServer("localhost", port1)); + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServer("localhost", proxyPort).setProxyType(proxyType)); Response response = client.executeRequest(rb.build()).get(); assertEquals(200, response.getStatusCode()); } } - @RepeatedIfExceptionsTest(repeats = 5) - public void testConfigProxy() throws Exception { + @ParameterizedTest(name = "{0}") + @MethodSource("proxyTypeProvider") + public void testConfigProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : this.proxyPort; + AsyncHttpClientConfig config = config() .setFollowRedirect(true) - .setProxyServer(proxyServer("localhost", port1).build()) + .setProxyServer(proxyServer("localhost", proxyPort).setProxyType(proxyType).build()) .setUseInsecureTrustManager(true) .build(); @@ -109,11 +151,14 @@ public void testConfigProxy() throws Exception { } } - @RepeatedIfExceptionsTest(repeats = 5) - public void testNoDirectRequestBodyWithProxy() throws Exception { + @ParameterizedTest(name = "{0}") + @MethodSource("proxyTypeProvider") + public void testNoDirectRequestBodyWithProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : this.proxyPort; + AsyncHttpClientConfig config = config() .setFollowRedirect(true) - .setProxyServer(proxyServer("localhost", port1).build()) + .setProxyServer(proxyServer("localhost", proxyPort).setProxyType(proxyType).build()) .setUseInsecureTrustManager(true) .build(); @@ -123,11 +168,14 @@ public void testNoDirectRequestBodyWithProxy() throws Exception { } } - @RepeatedIfExceptionsTest(repeats = 5) - public void testDecompressBodyWithProxy() throws Exception { + @ParameterizedTest(name = "{0}") + @MethodSource("proxyTypeProvider") + public void testDecompressBodyWithProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : this.proxyPort; + AsyncHttpClientConfig config = config() .setFollowRedirect(true) - .setProxyServer(proxyServer("localhost", port1).build()) + .setProxyServer(proxyServer("localhost", proxyPort).setProxyType(proxyType).build()) .setUseInsecureTrustManager(true) .build(); @@ -142,10 +190,13 @@ public void testDecompressBodyWithProxy() throws Exception { } } - @RepeatedIfExceptionsTest(repeats = 5) - public void testPooledConnectionsWithProxy() throws Exception { + @ParameterizedTest(name = "{0}") + @MethodSource("proxyTypeProvider") + public void testPooledConnectionsWithProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : this.proxyPort; + try (AsyncHttpClient asyncHttpClient = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true).setKeepAlive(true))) { - RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServer("localhost", port1)); + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServer("localhost", proxyPort).setProxyType(proxyType)); Response response1 = asyncHttpClient.executeRequest(rb.build()).get(); assertEquals(200, response1.getStatusCode()); @@ -155,12 +206,15 @@ public void testPooledConnectionsWithProxy() throws Exception { } } - @RepeatedIfExceptionsTest(repeats = 5) - public void testFailedConnectWithProxy() throws Exception { + @ParameterizedTest(name = "{0}") + @MethodSource("proxyTypeProvider") + public void testFailedConnectWithProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : this.proxyPort; + try (AsyncHttpClient asyncHttpClient = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true).setKeepAlive(true))) { - Builder proxyServer = proxyServer("localhost", port1); - proxyServer.setCustomHeaders(r -> new DefaultHttpHeaders().set(ProxyHandler.HEADER_FORBIDDEN, "1")); - RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServer); + Builder proxyServerBuilder = proxyServer("localhost", proxyPort).setProxyType(proxyType); + proxyServerBuilder.setCustomHeaders(r -> new DefaultHttpHeaders().set(ProxyHandler.HEADER_FORBIDDEN, "1")); + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServerBuilder); Response response1 = asyncHttpClient.executeRequest(rb.build()).get(); assertEquals(403, response1.getStatusCode()); @@ -173,13 +227,16 @@ public void testFailedConnectWithProxy() throws Exception { } } - @RepeatedIfExceptionsTest(repeats = 5) - public void testClosedConnectionWithProxy() throws Exception { + @ParameterizedTest(name = "{0}") + @MethodSource("proxyTypeProvider") + public void testClosedConnectionWithProxy(String testName, ProxyType proxyType) throws Exception { + int proxyPort = proxyType == ProxyType.HTTPS ? httpsProxyPort : this.proxyPort; + try (AsyncHttpClient asyncHttpClient = asyncHttpClient( config().setFollowRedirect(true).setUseInsecureTrustManager(true).setKeepAlive(true))) { - Builder proxyServer = proxyServer("localhost", port1); - proxyServer.setCustomHeaders(r -> new DefaultHttpHeaders().set(ProxyHandler.HEADER_FORBIDDEN, "2")); - RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServer); + Builder proxyServerBuilder = proxyServer("localhost", proxyPort).setProxyType(proxyType); + proxyServerBuilder.setCustomHeaders(r -> new DefaultHttpHeaders().set(ProxyHandler.HEADER_FORBIDDEN, "2")); + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(proxyServerBuilder); assertThrowsExactly(ExecutionException.class, () -> asyncHttpClient.executeRequest(rb.build()).get()); assertThrowsExactly(ExecutionException.class, () -> asyncHttpClient.executeRequest(rb.build()).get()); @@ -187,6 +244,49 @@ public void testClosedConnectionWithProxy() throws Exception { } } + @RepeatedIfExceptionsTest(repeats = 5) + public void testHttpsProxyType() throws Exception { + // Test that HTTPS proxy type can be configured and behaves correctly + ProxyServer.Builder builder = proxyServer("localhost", port1) + .setSecuredPort(443) + .setProxyType(ProxyType.HTTPS); + + ProxyServer proxy = builder.build(); + + assertEquals(ProxyType.HTTPS, proxy.getProxyType()); + assertEquals(true, proxy.getProxyType().isHttp()); + assertEquals(443, proxy.getSecuredPort()); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testHttpsProxyWithSecuredPortOnly() throws Exception { + // Test HTTPS proxy using only secured port (typical configuration) + try (AsyncHttpClient client = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true))) { + ProxyServer httpsProxy = proxyServer("localhost", httpsProxyPort) + .setProxyType(ProxyType.HTTPS) + .build(); + + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(httpsProxy); + Response response = client.executeRequest(rb.build()).get(); + assertEquals(200, response.getStatusCode()); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testHttpsProxyWithAuthentication() throws Exception { + // Test HTTPS proxy with custom headers (simulating authentication) + try (AsyncHttpClient client = asyncHttpClient(config().setFollowRedirect(true).setUseInsecureTrustManager(true))) { + ProxyServer httpsProxy = proxyServer("localhost", httpsProxyPort) + .setProxyType(ProxyType.HTTPS) + .setCustomHeaders(request -> new DefaultHttpHeaders().set("Proxy-Authorization", "Bearer test-token")) + .build(); + + RequestBuilder rb = get(getTargetUrl2()).setProxyServer(httpsProxy); + Response response = client.executeRequest(rb.build()).get(); + assertEquals(200, response.getStatusCode()); + } + } + public static class ProxyHandler extends ConnectHandler { final static String HEADER_FORBIDDEN = "X-REJECT-REQUEST"; diff --git a/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java new file mode 100644 index 000000000..e915e8666 --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/proxy/HttpsProxyTestcontainersIntegrationTest.java @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 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 io.github.artsok.RepeatedIfExceptionsTest; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.AsyncHttpClientConfig; +import org.asynchttpclient.Response; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static org.asynchttpclient.Dsl.asyncHttpClient; +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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +@Testcontainers +public class HttpsProxyTestcontainersIntegrationTest { + + private static final Logger LOGGER = LoggerFactory.getLogger(HttpsProxyTestcontainersIntegrationTest.class); + + private static final int SQUID_HTTP_PORT = 3128; + private static final int SQUID_HTTPS_PORT = 3129; + + private static final String TARGET_HTTP_URL = "http://httpbin.org/get"; + private static final String TARGET_HTTPS_URL = "https://www.example.com/"; + + private static boolean dockerAvailable = false; + private static GenericContainer squidProxy; + + @BeforeAll + static void checkDockerAvailability() { + try { + dockerAvailable = DockerClientFactory.instance().isDockerAvailable(); + LOGGER.info("Docker availability check: {}", dockerAvailable); + } catch (Exception e) { + LOGGER.warn("Failed to check Docker availability: {}", e.getMessage()); + dockerAvailable = false; + } + // Skip tests if Docker not available, unless force-enabled + if (!dockerAvailable && !"true".equals(System.getProperty("docker.tests"))) { + assumeTrue(false, "Docker is not available - skipping integration tests. Use -Ddocker.tests=true to force run."); + } + // Allow force-disabling Docker tests + if ("true".equals(System.getProperty("no.docker.tests"))) { + assumeTrue(false, "Docker tests disabled via -Dno.docker.tests=true"); + } + // Only start container if Docker is available + if (dockerAvailable) { + squidProxy = new GenericContainer<>( + new ImageFromDockerfile() + .withFileFromPath("Dockerfile", Path.of("src/test/resources/squid/Dockerfile")) + .withFileFromPath("squid.conf", Path.of("src/test/resources/squid/squid.conf")) + ) + .withExposedPorts(SQUID_HTTP_PORT, SQUID_HTTPS_PORT) + .withLogConsumer(new Slf4jLogConsumer(LOGGER).withPrefix("SQUID")) + .waitingFor(Wait.forLogMessage(".*Accepting HTTP.*", 1) + .withStartupTimeout(Duration.ofMinutes(2))); + squidProxy.start(); + } + } + + @AfterAll + static void stopContainer() { + if (squidProxy != null && squidProxy.isRunning()) { + squidProxy.stop(); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testHttpProxyToHttpTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing HTTP proxy to HTTP target"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", squidProxy.getMappedPort(SQUID_HTTP_PORT)) + .setProxyType(ProxyType.HTTP) + .build()) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTP_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("httpbin")); + LOGGER.info("HTTP proxy to HTTP target test passed"); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testHttpsProxyToHttpTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing HTTPS proxy to HTTP target"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", squidProxy.getMappedPort(SQUID_HTTPS_PORT)) + .setProxyType(ProxyType.HTTPS) + .build()) + .setUseInsecureTrustManager(true) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTP_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("httpbin")); + LOGGER.info("HTTPS proxy to HTTP target test passed"); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testHttpProxyToHttpsTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing HTTP proxy to HTTPS target"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", squidProxy.getMappedPort(SQUID_HTTP_PORT)) + .setProxyType(ProxyType.HTTP) + .build()) + .setUseInsecureTrustManager(true) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTPS_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("Example Domain") || + response.getResponseBody().contains("example")); + LOGGER.info("HTTP proxy to HTTPS target test passed"); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testHttpsProxyToHttpsTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing HTTPS proxy to HTTPS target - validates issue #1907 fix"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", squidProxy.getMappedPort(SQUID_HTTPS_PORT)) + .setProxyType(ProxyType.HTTPS) + .build()) + .setUseInsecureTrustManager(true) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTPS_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("Example Domain") || + response.getResponseBody().contains("example")); + LOGGER.info("HTTPS proxy to HTTPS target test passed - core issue #1907 RESOLVED!"); + } + } + + @Test + public void testDockerInfrastructureReady() { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Docker infrastructure test - validating container is ready"); + LOGGER.info("Squid HTTP proxy available at: localhost:{}", squidProxy.getMappedPort(SQUID_HTTP_PORT)); + LOGGER.info("Squid HTTPS proxy available at: localhost:{}", squidProxy.getMappedPort(SQUID_HTTPS_PORT)); + assertTrue(squidProxy.isRunning(), "Squid container should be running"); + assertTrue(squidProxy.getMappedPort(SQUID_HTTP_PORT) > 0, "HTTP port should be mapped"); + assertTrue(squidProxy.getMappedPort(SQUID_HTTPS_PORT) > 0, "HTTPS port should be mapped"); + LOGGER.info("Docker infrastructure is ready and accessible"); + } +} diff --git a/client/src/test/resources/squid/Dockerfile b/client/src/test/resources/squid/Dockerfile new file mode 100644 index 000000000..5ba0372b7 --- /dev/null +++ b/client/src/test/resources/squid/Dockerfile @@ -0,0 +1,26 @@ +FROM ubuntu/squid:latest + +# Install OpenSSL for certificate generation +RUN apt-get update && \ + apt-get install -y openssl && \ + rm -rf /var/lib/apt/lists/* && \ + mkdir -p /etc/squid/certs /var/log/squid && \ + chown -R proxy:proxy /var/log/squid /etc/squid/certs + +# Generate self-signed certificate for localhost +RUN openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout /etc/squid/certs/proxy.key \ + -out /etc/squid/certs/proxy.crt \ + -subj "/CN=localhost" && \ + cat /etc/squid/certs/proxy.key /etc/squid/certs/proxy.crt > /etc/squid/certs/proxy.pem && \ + chmod 600 /etc/squid/certs/proxy.key /etc/squid/certs/proxy.pem && \ + chmod 644 /etc/squid/certs/proxy.crt && \ + chown -R proxy:proxy /etc/squid/certs + +# Copy squid configuration +COPY squid.conf /etc/squid/squid.conf +RUN chown proxy:proxy /etc/squid/squid.conf + +EXPOSE 3128 3129 + +CMD ["squid", "-f", "/etc/squid/squid.conf", "-NYCd", "1"] \ No newline at end of file diff --git a/client/src/test/resources/squid/squid.conf b/client/src/test/resources/squid/squid.conf new file mode 100644 index 000000000..5c317089f --- /dev/null +++ b/client/src/test/resources/squid/squid.conf @@ -0,0 +1,19 @@ +# HTTP and HTTPS proxy ports +http_port 0.0.0.0:3128 +https_port 0.0.0.0:3129 tls-cert=/etc/squid/certs/proxy.pem + +# Allow all access for testing +http_access allow all + +# Disable caching for testing +cache deny all + +# Logging configuration +access_log /var/log/squid/access.log squid +cache_log /var/log/squid/cache.log + +# Performance settings +maximum_object_size_in_memory 512 KB +maximum_object_size 1 GB +cache_dir null /tmp +pid_filename /var/run/squid.pid \ No newline at end of file diff --git a/pom.xml b/pom.xml index 9d64fc54b..252230a42 100644 --- a/pom.xml +++ b/pom.xml @@ -53,6 +53,7 @@ 2.0.1 1.5.18 26.0.2 + 1.20.4 From af520cca2987d70909d97733865a6311987a1ae6 Mon Sep 17 00:00:00 2001 From: Aayush Atharva Date: Fri, 5 Sep 2025 16:09:47 +0530 Subject: [PATCH 16/23] Upgrade to Netty 4.2 (#2112) --- .../netty/channel/ChannelManager.java | 8 +++---- ...tory.java => IoUringTransportFactory.java} | 21 ++++++++++--------- .../DefaultAsyncHttpClientTest.java | 4 ++-- .../asynchttpclient/MultipleHeaderTest.java | 2 ++ .../org/asynchttpclient/netty/NettyTest.java | 4 ++-- client/src/test/resources/logback-test.xml | 1 + pom.xml | 14 ++++++------- 7 files changed, 29 insertions(+), 25 deletions(-) rename client/src/main/java/org/asynchttpclient/netty/channel/{IoUringIncubatorTransportFactory.java => IoUringTransportFactory.java} (56%) diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java index fc55d453d..e9f4d111e 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java @@ -165,8 +165,8 @@ public ChannelManager(final AsyncHttpClientConfig config, Timer nettyTimer) { transportFactory = new EpollTransportFactory(); } else if (isInstanceof(eventLoopGroup, "io.netty.channel.kqueue.KQueueEventLoopGroup")) { transportFactory = new KQueueTransportFactory(); - } else if (isInstanceof(eventLoopGroup, "io.netty.incubator.channel.uring.IOUringEventLoopGroup")) { - transportFactory = new IoUringIncubatorTransportFactory(); + } else if (isInstanceof(eventLoopGroup, "io.netty.channel.uring.IOUringEventLoopGroup")) { + transportFactory = new IoUringTransportFactory(); } else { throw new IllegalArgumentException("Unknown event loop group " + eventLoopGroup.getClass().getSimpleName()); } @@ -190,8 +190,8 @@ public ChannelManager(final AsyncHttpClientConfig config, Timer nettyTimer) { // We will check if Epoll is available or not. If available, return EpollTransportFactory. // If none of the condition matches then no native transport is available, and we will throw an exception. if (!PlatformDependent.isWindows()) { - if (IoUringIncubatorTransportFactory.isAvailable() && !config.isUseOnlyEpollNativeTransport()) { - return new IoUringIncubatorTransportFactory(); + if (IoUringTransportFactory.isAvailable() && !config.isUseOnlyEpollNativeTransport()) { + return new IoUringTransportFactory(); } else if (EpollTransportFactory.isAvailable()) { return new EpollTransportFactory(); } diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/IoUringIncubatorTransportFactory.java b/client/src/main/java/org/asynchttpclient/netty/channel/IoUringTransportFactory.java similarity index 56% rename from client/src/main/java/org/asynchttpclient/netty/channel/IoUringIncubatorTransportFactory.java rename to client/src/main/java/org/asynchttpclient/netty/channel/IoUringTransportFactory.java index 2065ef10b..a93250185 100644 --- a/client/src/main/java/org/asynchttpclient/netty/channel/IoUringIncubatorTransportFactory.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/IoUringTransportFactory.java @@ -15,30 +15,31 @@ */ package org.asynchttpclient.netty.channel; -import io.netty.incubator.channel.uring.IOUring; -import io.netty.incubator.channel.uring.IOUringEventLoopGroup; -import io.netty.incubator.channel.uring.IOUringSocketChannel; +import io.netty.channel.MultiThreadIoEventLoopGroup; +import io.netty.channel.uring.IoUring; +import io.netty.channel.uring.IoUringIoHandler; +import io.netty.channel.uring.IoUringSocketChannel; import java.util.concurrent.ThreadFactory; -class IoUringIncubatorTransportFactory implements TransportFactory { +class IoUringTransportFactory implements TransportFactory { static boolean isAvailable() { try { - Class.forName("io.netty.incubator.channel.uring.IOUring"); + Class.forName("io.netty.channel.uring.IoUring"); } catch (ClassNotFoundException e) { return false; } - return IOUring.isAvailable(); + return IoUring.isAvailable(); } @Override - public IOUringSocketChannel newChannel() { - return new IOUringSocketChannel(); + public IoUringSocketChannel newChannel() { + return new IoUringSocketChannel(); } @Override - public IOUringEventLoopGroup newEventLoopGroup(int ioThreadsCount, ThreadFactory threadFactory) { - return new IOUringEventLoopGroup(ioThreadsCount, threadFactory); + public MultiThreadIoEventLoopGroup newEventLoopGroup(int ioThreadsCount, ThreadFactory threadFactory) { + return new MultiThreadIoEventLoopGroup(ioThreadsCount, threadFactory, IoUringIoHandler.newFactory()); } } diff --git a/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientTest.java b/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientTest.java index fc7a1c2db..f2f89d3f9 100644 --- a/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientTest.java +++ b/client/src/test/java/org/asynchttpclient/DefaultAsyncHttpClientTest.java @@ -16,9 +16,9 @@ package org.asynchttpclient; import io.github.artsok.RepeatedIfExceptionsTest; +import io.netty.channel.MultiThreadIoEventLoopGroup; import io.netty.channel.epoll.EpollEventLoopGroup; import io.netty.channel.kqueue.KQueueEventLoopGroup; -import io.netty.incubator.channel.uring.IOUringEventLoopGroup; import io.netty.util.Timer; import org.asynchttpclient.cookie.CookieEvictionTask; import org.asynchttpclient.cookie.CookieStore; @@ -61,7 +61,7 @@ public void testNativeTransportWithoutEpollOnly() throws Exception { AsyncHttpClientConfig config = config().setUseNativeTransport(true).setUseOnlyEpollNativeTransport(false).build(); try (DefaultAsyncHttpClient client = (DefaultAsyncHttpClient) asyncHttpClient(config)) { assertDoesNotThrow(() -> client.prepareGet("https://www.google.com").execute().get()); - assertInstanceOf(IOUringEventLoopGroup.class, client.channelManager().getEventLoopGroup()); + assertInstanceOf(MultiThreadIoEventLoopGroup.class, client.channelManager().getEventLoopGroup()); } } diff --git a/client/src/test/java/org/asynchttpclient/MultipleHeaderTest.java b/client/src/test/java/org/asynchttpclient/MultipleHeaderTest.java index cf6dbc353..6414f6e4f 100644 --- a/client/src/test/java/org/asynchttpclient/MultipleHeaderTest.java +++ b/client/src/test/java/org/asynchttpclient/MultipleHeaderTest.java @@ -16,6 +16,7 @@ import io.netty.handler.codec.http.HttpHeaders; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; import javax.net.ServerSocketFactory; import java.io.BufferedReader; @@ -39,6 +40,7 @@ /** * @author Hubert Iwaniuk */ +@Disabled("New Netty Release Prevent Invalid Line in HTTP Header") public class MultipleHeaderTest extends AbstractBasicTest { private static ExecutorService executorService; private static ServerSocket serverSocket; diff --git a/client/src/test/java/org/asynchttpclient/netty/NettyTest.java b/client/src/test/java/org/asynchttpclient/netty/NettyTest.java index f80c0911e..c7d7e1d1d 100644 --- a/client/src/test/java/org/asynchttpclient/netty/NettyTest.java +++ b/client/src/test/java/org/asynchttpclient/netty/NettyTest.java @@ -2,9 +2,9 @@ import io.netty.channel.epoll.Epoll; import io.netty.channel.kqueue.KQueue; +import io.netty.channel.uring.IoUring; import io.netty.handler.codec.compression.Brotli; import io.netty.handler.codec.compression.Zstd; -import io.netty.incubator.channel.uring.IOUring; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledOnOs; import org.junit.jupiter.api.condition.OS; @@ -21,7 +21,7 @@ public void epollIsAvailableOnLinux() { @Test @EnabledOnOs(OS.LINUX) public void ioUringIsAvailableOnLinux() { - assertTrue(IOUring.isAvailable()); + assertTrue(IoUring.isAvailable()); } @Test diff --git a/client/src/test/resources/logback-test.xml b/client/src/test/resources/logback-test.xml index 4b6a08791..f9d903997 100644 --- a/client/src/test/resources/logback-test.xml +++ b/client/src/test/resources/logback-test.xml @@ -7,6 +7,7 @@ + diff --git a/pom.xml b/pom.xml index 252230a42..e2a3f7854 100644 --- a/pom.xml +++ b/pom.xml @@ -45,7 +45,7 @@ 11 UTF-8 - 4.1.119.Final + 4.2.5.Final 0.0.26.Final 1.18.0 2.0.16 @@ -206,17 +206,17 @@ - io.netty.incubator - netty-incubator-transport-native-io_uring - ${netty.iouring} + io.netty + netty-transport-native-io_uring + ${netty.version} linux-x86_64 true - io.netty.incubator - netty-incubator-transport-native-io_uring - ${netty.iouring} + io.netty + netty-transport-native-io_uring + ${netty.version} linux-aarch_64 true From e96ceb951fcdbe528fe6c137c831adb7808829f4 Mon Sep 17 00:00:00 2001 From: Aayush Atharva Date: Mon, 8 Sep 2025 01:05:26 +0530 Subject: [PATCH 17/23] Release v3.0.3 (#2113) --- .github/workflows/release.yml | 2 +- README.md | 4 ++-- client/pom.xml | 2 +- .../org/asynchttpclient/AsyncHttpClientConfig.java | 5 ++++- pom.xml | 13 +++++-------- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b175fa865..059d1640f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: with: servers: | [{ - "id": "ossrh", + "id": "central", "username": "${{ secrets.OSSRH_USERNAME }}", "password": "${{ secrets.OSSRH_PASSWORD }}" }] diff --git a/README.md b/README.md index 0272134ed..61621a682 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Maven: org.asynchttpclient async-http-client - 3.0.2 + 3.0.3 ``` @@ -28,7 +28,7 @@ Maven: Gradle: ```groovy dependencies { - implementation 'org.asynchttpclient:async-http-client:3.0.2' + implementation 'org.asynchttpclient:async-http-client:3.0.3' } ``` diff --git a/client/pom.xml b/client/pom.xml index 9c0cefee3..019e294c0 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -19,7 +19,7 @@ org.asynchttpclient async-http-client-project - 3.0.2 + 3.0.3 4.0.0 diff --git a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java index 954628b3d..216dc4ed6 100644 --- a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java +++ b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java @@ -380,7 +380,10 @@ public interface AsyncHttpClientConfig { * * @return true if the Authorization header should be stripped, false otherwise. */ - boolean isStripAuthorizationOnRedirect(); + default boolean isStripAuthorizationOnRedirect() { + // By default, we throw, so that existing implementations don't break. + throw new UnsupportedOperationException("IsStripAuthorizationOnRedirect is not supported by " + getClass().getName()); + } enum ResponseBodyPartFactory { diff --git a/pom.xml b/pom.xml index e2a3f7854..22b72aa2c 100644 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ org.asynchttpclient async-http-client-project - 3.0.2 + 3.0.3 pom AHC/Project @@ -394,15 +394,12 @@ - org.sonatype.plugins - nexus-staging-maven-plugin - 1.7.0 + org.sonatype.central + central-publishing-maven-plugin + 0.8.0 true - ossrh - https://oss.sonatype.org/ - false - false + central From 4fadd37d9815a8e9cdff980a80b8922ebf6821e4 Mon Sep 17 00:00:00 2001 From: Aayush Atharva Date: Sat, 13 Sep 2025 04:06:25 +0530 Subject: [PATCH 18/23] Fix SOCKS proxy SSL handler issue - resolve NoSuchElementException when using HTTPS with SOCKS4/SOCKS5 (#2114) Motivation: SOCKS proxy support for HTTPS requests was broken when adding the SSL handler after the SOCKS handler. Modification: Fixed Netty pipeline logic to prevent `NoSuchElementException` when adding SSL handler after SOCKS handler, restoring HTTPS support for SOCKS4/SOCKS5 proxies. Fixes: #1913 --- .../netty/channel/ChannelManager.java | 5 +- .../asynchttpclient/proxy/SocksProxyTest.java | 252 ++++++++++++++++++ ...cksProxyTestcontainersIntegrationTest.java | 223 ++++++++++++++++ client/src/test/resources/dante/Dockerfile | 19 ++ client/src/test/resources/dante/sockd.conf | 23 ++ 5 files changed, 520 insertions(+), 2 deletions(-) create mode 100644 client/src/test/java/org/asynchttpclient/proxy/SocksProxyTest.java create mode 100644 client/src/test/java/org/asynchttpclient/proxy/SocksProxyTestcontainersIntegrationTest.java create mode 100644 client/src/test/resources/dante/Dockerfile create mode 100644 client/src/test/resources/dante/sockd.conf diff --git a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java index e9f4d111e..8d13361ae 100755 --- a/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java +++ b/client/src/main/java/org/asynchttpclient/netty/channel/ChannelManager.java @@ -485,7 +485,8 @@ public SslHandler addSslHandler(ChannelPipeline pipeline, Uri uri, String virtua } SslHandler sslHandler = createSslHandler(peerHost, peerPort); - if (hasSocksProxyHandler) { + // Check if SOCKS handler actually exists in the pipeline before trying to add after it + if (hasSocksProxyHandler && pipeline.get(SOCKS_HANDLER) != null) { pipeline.addAfter(SOCKS_HANDLER, SSL_HANDLER, sslHandler); } else { pipeline.addFirst(SSL_HANDLER, sslHandler); @@ -614,4 +615,4 @@ public ClientStats getClientStats() { public boolean isOpen() { return channelPool.isOpen(); } -} +} \ No newline at end of file diff --git a/client/src/test/java/org/asynchttpclient/proxy/SocksProxyTest.java b/client/src/test/java/org/asynchttpclient/proxy/SocksProxyTest.java new file mode 100644 index 000000000..e1870721a --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/proxy/SocksProxyTest.java @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2024 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 io.github.artsok.RepeatedIfExceptionsTest; +import org.asynchttpclient.AbstractBasicTest; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.Response; +import org.asynchttpclient.testserver.SocksProxy; +import org.eclipse.jetty.server.handler.AbstractHandler; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.asynchttpclient.Dsl.asyncHttpClient; +import static org.asynchttpclient.Dsl.config; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Tests for SOCKS proxy support with both HTTP and HTTPS. + */ +public class SocksProxyTest extends AbstractBasicTest { + + @Override + public AbstractHandler configureHandler() throws Exception { + return new ProxyTest.ProxyHandler(); + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testSocks4ProxyWithHttp() throws Exception { + // Start SOCKS proxy in background thread + Thread socksProxyThread = new Thread(() -> { + try { + new SocksProxy(60000); + } catch (Exception e) { + logger.error("Failed to establish SocksProxy", e); + } + }); + socksProxyThread.start(); + + // Give the proxy time to start + Thread.sleep(1000); + + try (AsyncHttpClient client = asyncHttpClient()) { + String target = "http://localhost:" + port1 + '/'; + Future f = client.prepareGet(target) + .setProxyServer(new ProxyServer.Builder("localhost", 8000).setProxyType(ProxyType.SOCKS_V4)) + .execute(); + + Response response = f.get(60, TimeUnit.SECONDS); + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void testSocks5ProxyWithHttp() throws Exception { + // Start SOCKS proxy in background thread + Thread socksProxyThread = new Thread(() -> { + try { + new SocksProxy(60000); + } catch (Exception e) { + logger.error("Failed to establish SocksProxy", e); + } + }); + socksProxyThread.start(); + + // Give the proxy time to start + Thread.sleep(1000); + + try (AsyncHttpClient client = asyncHttpClient()) { + String target = "http://localhost:" + port1 + '/'; + Future f = client.prepareGet(target) + .setProxyServer(new ProxyServer.Builder("localhost", 8000).setProxyType(ProxyType.SOCKS_V5)) + .execute(); + + Response response = f.get(60, TimeUnit.SECONDS); + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + } + } + + @Test + public void testSocks5ProxyWithHttpsDoesNotThrowException() throws Exception { + // This test specifically verifies that HTTPS requests through SOCKS5 proxy + // do not throw NoSuchElementException: socks anymore + + // Start SOCKS proxy in background thread + Thread socksProxyThread = new Thread(() -> { + try { + new SocksProxy(10000); // shorter time for test + } catch (Exception e) { + logger.error("Failed to establish SocksProxy", e); + } + }); + socksProxyThread.start(); + + // Give the proxy time to start + Thread.sleep(1000); + + try (AsyncHttpClient client = asyncHttpClient(config() + .setProxyServer(new ProxyServer.Builder("localhost", 8000).setProxyType(ProxyType.SOCKS_V5)) + .setConnectTimeout(Duration.ofMillis(5000)) + .setRequestTimeout(Duration.ofMillis(10000)))) { + + // This would previously throw: java.util.NoSuchElementException: socks + // We expect this to fail with connection timeout (since we don't have a real HTTPS target) + // but NOT with NoSuchElementException + + try { + Future f = client.prepareGet("https://httpbin.org/get").execute(); + f.get(8, TimeUnit.SECONDS); + // If we reach here, great! The SOCKS proxy worked + } catch (Exception e) { + // We should NOT see NoSuchElementException: socks anymore + String message = e.getMessage(); + if (message != null && message.contains("socks") && message.contains("NoSuchElementException")) { + throw new AssertionError("NoSuchElementException: socks still occurs", e); + } + // Other exceptions like connection timeout are expected since we don't have a real working SOCKS proxy setup + logger.info("Expected exception (not the SOCKS handler bug): " + e.getClass().getSimpleName() + ": " + message); + } + } + } + + @Test + public void testSocks4ProxyWithHttpsDoesNotThrowException() throws Exception { + // This test specifically verifies that HTTPS requests through SOCKS4 proxy + // do not throw NoSuchElementException: socks anymore + + // Start SOCKS proxy in background thread + Thread socksProxyThread = new Thread(() -> { + try { + new SocksProxy(10000); // shorter time for test + } catch (Exception e) { + logger.error("Failed to establish SocksProxy", e); + } + }); + socksProxyThread.start(); + + // Give the proxy time to start + Thread.sleep(1000); + + try (AsyncHttpClient client = asyncHttpClient(config() + .setProxyServer(new ProxyServer.Builder("localhost", 8000).setProxyType(ProxyType.SOCKS_V4)) + .setConnectTimeout(Duration.ofMillis(5000)) + .setRequestTimeout(Duration.ofMillis(10000)))) { + + // This would previously throw: java.util.NoSuchElementException: socks + // We expect this to fail with connection timeout (since we don't have a real HTTPS target) + // but NOT with NoSuchElementException + + try { + Future f = client.prepareGet("https://httpbin.org/get").execute(); + f.get(8, TimeUnit.SECONDS); + // If we reach here, great! The SOCKS proxy worked + } catch (Exception e) { + // We should NOT see NoSuchElementException: socks anymore + String message = e.getMessage(); + if (message != null && message.contains("socks") && message.contains("NoSuchElementException")) { + throw new AssertionError("NoSuchElementException: socks still occurs", e); + } + // Other exceptions like connection timeout are expected since we don't have a real working SOCKS proxy setup + logger.info("Expected exception (not the SOCKS handler bug): " + e.getClass().getSimpleName() + ": " + message); + } + } + } + + @Test + public void testIssue1913NoSuchElementExceptionSocks5() throws Exception { + // Reproduces the exact issue from GitHub issue #1913 with SOCKS5 + // This uses the exact code pattern from the issue report + var proxyServer = new ProxyServer.Builder("127.0.0.1", 1081) + .setProxyType(ProxyType.SOCKS_V5); + + try (var client = asyncHttpClient(config() + .setProxyServer(proxyServer.build()) + .setConnectTimeout(Duration.ofMillis(2000)) + .setRequestTimeout(Duration.ofMillis(5000)))) { + + // This would previously throw: java.util.NoSuchElementException: socks + // We expect this to fail with connection timeout (since proxy doesn't exist) + // but NOT with NoSuchElementException + + try { + var response = client.prepareGet("https://cloudflare.com/cdn-cgi/trace").execute().get(); + // If we reach here, great! The fix worked and proxy connection succeeded + logger.info("Connection successful: " + response.getStatusCode()); + } catch (Exception e) { + // Check that we don't get the NoSuchElementException: socks anymore + Throwable cause = e.getCause(); + String message = cause != null ? cause.getMessage() : e.getMessage(); + + // This should NOT contain the original error + if (message != null && message.contains("socks") && + (e.toString().contains("NoSuchElementException") || cause != null && cause.toString().contains("NoSuchElementException"))) { + throw new AssertionError("NoSuchElementException: socks still occurs - fix didn't work: " + e.toString()); + } + + // Other exceptions like connection timeout are expected since we don't have a working SOCKS proxy + logger.info("Expected exception (not the SOCKS handler bug): " + e.getClass().getSimpleName() + ": " + message); + } + } + } + + @Test + public void testIssue1913NoSuchElementExceptionSocks4() throws Exception { + // Reproduces the exact issue from GitHub issue #1913 with SOCKS4 + // This uses the exact code pattern from the issue report + var proxyServer = new ProxyServer.Builder("127.0.0.1", 1081) + .setProxyType(ProxyType.SOCKS_V4); + + try (var client = asyncHttpClient(config() + .setProxyServer(proxyServer.build()) + .setConnectTimeout(Duration.ofMillis(2000)) + .setRequestTimeout(Duration.ofMillis(5000)))) { + + try { + var response = client.prepareGet("https://cloudflare.com/cdn-cgi/trace").execute().get(); + logger.info("Connection successful: " + response.getStatusCode()); + } catch (Exception e) { + // Check that we don't get the NoSuchElementException: socks anymore + Throwable cause = e.getCause(); + String message = cause != null ? cause.getMessage() : e.getMessage(); + + if (message != null && message.contains("socks") && + (e.toString().contains("NoSuchElementException") || cause != null && cause.toString().contains("NoSuchElementException"))) { + throw new AssertionError("NoSuchElementException: socks still occurs - fix didn't work: " + e.toString()); + } + + logger.info("Expected exception (not the SOCKS handler bug): " + e.getClass().getSimpleName() + ": " + message); + } + } + } +} diff --git a/client/src/test/java/org/asynchttpclient/proxy/SocksProxyTestcontainersIntegrationTest.java b/client/src/test/java/org/asynchttpclient/proxy/SocksProxyTestcontainersIntegrationTest.java new file mode 100644 index 000000000..4308f388e --- /dev/null +++ b/client/src/test/java/org/asynchttpclient/proxy/SocksProxyTestcontainersIntegrationTest.java @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2025 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 io.github.artsok.RepeatedIfExceptionsTest; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.AsyncHttpClientConfig; +import org.asynchttpclient.Response; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static org.asynchttpclient.Dsl.asyncHttpClient; +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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Integration tests for SOCKS proxy support using Dante SOCKS server in TestContainers. + * This validates the fix for GitHub issue #1913. + */ +@Testcontainers +public class SocksProxyTestcontainersIntegrationTest { + + private static final Logger LOGGER = LoggerFactory.getLogger(SocksProxyTestcontainersIntegrationTest.class); + + private static final int SOCKS_PORT = 1080; + + private static final String TARGET_HTTP_URL = "http://httpbin.org/get"; + private static final String TARGET_HTTPS_URL = "https://www.example.com/"; + + private static boolean dockerAvailable = false; + private static GenericContainer socksProxy; + + @BeforeAll + static void checkDockerAvailability() { + try { + dockerAvailable = DockerClientFactory.instance().isDockerAvailable(); + LOGGER.info("Docker availability check: {}", dockerAvailable); + } catch (Exception e) { + LOGGER.warn("Failed to check Docker availability: {}", e.getMessage()); + dockerAvailable = false; + } + // Skip tests if Docker not available, unless force-enabled + if (!dockerAvailable && !"true".equals(System.getProperty("docker.tests"))) { + LOGGER.info("Docker is not available - skipping integration tests. Use -Ddocker.tests=true to force run."); + return; // Don't start container if Docker not available + } + // Allow force-disabling Docker tests + if ("true".equals(System.getProperty("no.docker.tests"))) { + LOGGER.info("Docker tests disabled via -Dno.docker.tests=true"); + return; + } + // Only start container if Docker is available + if (dockerAvailable) { + try { + socksProxy = new GenericContainer<>( + new ImageFromDockerfile() + .withFileFromPath("Dockerfile", Path.of("src/test/resources/dante/Dockerfile")) + .withFileFromPath("sockd.conf", Path.of("src/test/resources/dante/sockd.conf")) + ) + .withExposedPorts(SOCKS_PORT) + .withLogConsumer(new Slf4jLogConsumer(LOGGER).withPrefix("DANTE")) + .waitingFor(Wait.forLogMessage(".*sockd.*", 1) + .withStartupTimeout(Duration.ofMinutes(2))); + socksProxy.start(); + LOGGER.info("Dante SOCKS proxy started successfully on port {}", socksProxy.getMappedPort(SOCKS_PORT)); + } catch (Exception e) { + LOGGER.warn("Failed to start Dante SOCKS proxy container: {}", e.getMessage()); + dockerAvailable = false; // Mark as unavailable if container start fails + } + } + } + + @AfterAll + static void stopContainer() { + if (socksProxy != null && socksProxy.isRunning()) { + socksProxy.stop(); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testSocks4ProxyToHttpTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing SOCKS4 proxy to HTTP target"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", socksProxy.getMappedPort(SOCKS_PORT)) + .setProxyType(ProxyType.SOCKS_V4) + .build()) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTP_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("httpbin")); + LOGGER.info("SOCKS4 proxy to HTTP target test passed"); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testSocks5ProxyToHttpTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing SOCKS5 proxy to HTTP target"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", socksProxy.getMappedPort(SOCKS_PORT)) + .setProxyType(ProxyType.SOCKS_V5) + .build()) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTP_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("httpbin")); + LOGGER.info("SOCKS5 proxy to HTTP target test passed"); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testSocks4ProxyToHttpsTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing SOCKS4 proxy to HTTPS target - validates issue #1913 fix"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", socksProxy.getMappedPort(SOCKS_PORT)) + .setProxyType(ProxyType.SOCKS_V4) + .build()) + .setUseInsecureTrustManager(true) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTPS_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("Example Domain") || + response.getResponseBody().contains("example")); + LOGGER.info("SOCKS4 proxy to HTTPS target test passed - issue #1913 RESOLVED!"); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testSocks5ProxyToHttpsTarget() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing SOCKS5 proxy to HTTPS target - validates issue #1913 fix"); + AsyncHttpClientConfig config = config() + .setProxyServer(proxyServer("localhost", socksProxy.getMappedPort(SOCKS_PORT)) + .setProxyType(ProxyType.SOCKS_V5) + .build()) + .setUseInsecureTrustManager(true) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)) + .build(); + try (AsyncHttpClient client = asyncHttpClient(config)) { + Response response = client.executeRequest(get(TARGET_HTTPS_URL)).get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("Example Domain") || + response.getResponseBody().contains("example")); + LOGGER.info("SOCKS5 proxy to HTTPS target test passed - issue #1913 RESOLVED!"); + } + } + + @RepeatedIfExceptionsTest(repeats = 3) + public void testIssue1913ReproductionWithRealProxy() throws Exception { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Testing exact issue #1913 reproduction with real SOCKS proxy"); + + // This reproduces the exact scenario from the GitHub issue but with a real working proxy + var proxyServer = proxyServer("localhost", socksProxy.getMappedPort(SOCKS_PORT)) + .setProxyType(ProxyType.SOCKS_V5); + + try (var client = asyncHttpClient(config() + .setProxyServer(proxyServer) + .setUseInsecureTrustManager(true) + .setConnectTimeout(Duration.ofMillis(10000)) + .setRequestTimeout(Duration.ofMillis(30000)))) { + + // This would previously throw: java.util.NoSuchElementException: socks + var response = client.prepareGet("https://www.example.com/").execute().get(30, TimeUnit.SECONDS); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getResponseBody().contains("Example Domain") || + response.getResponseBody().contains("example")); + LOGGER.info("Issue #1913 reproduction test PASSED - NoSuchElementException: socks is FIXED!"); + } + } + + @Test + public void testDockerInfrastructureReady() { + assumeTrue(dockerAvailable, "Docker is not available - skipping test"); + LOGGER.info("Docker infrastructure test - validating Dante SOCKS container is ready"); + LOGGER.info("Dante SOCKS proxy available at: localhost:{}", socksProxy.getMappedPort(SOCKS_PORT)); + assertTrue(socksProxy.isRunning(), "Dante SOCKS container should be running"); + assertTrue(socksProxy.getMappedPort(SOCKS_PORT) > 0, "SOCKS port should be mapped"); + LOGGER.info("Dante SOCKS infrastructure is ready and accessible"); + } +} diff --git a/client/src/test/resources/dante/Dockerfile b/client/src/test/resources/dante/Dockerfile new file mode 100644 index 000000000..a98658439 --- /dev/null +++ b/client/src/test/resources/dante/Dockerfile @@ -0,0 +1,19 @@ +FROM ubuntu:22.04 + +# Install Dante SOCKS server +RUN apt-get update && \ + apt-get install -y dante-server && \ + rm -rf /var/lib/apt/lists/* + +# Copy dante configuration +COPY sockd.conf /etc/sockd.conf + +# Create run directory +RUN mkdir -p /var/run/sockd && \ + chmod 755 /var/run/sockd + +# Expose SOCKS port +EXPOSE 1080 + +# Run dante server (sockd binary is in /usr/sbin) +CMD ["/usr/sbin/sockd", "-f", "/etc/sockd.conf", "-D"] diff --git a/client/src/test/resources/dante/sockd.conf b/client/src/test/resources/dante/sockd.conf new file mode 100644 index 000000000..e4f7ed0fd --- /dev/null +++ b/client/src/test/resources/dante/sockd.conf @@ -0,0 +1,23 @@ +# Basic SOCKS proxy configuration for testing +# Allow all connections and methods for testing purposes + +# Server configuration - listen on all interfaces +internal: 0.0.0.0 port = 1080 +external: eth0 + +# Authentication method - no authentication for testing +socksmethod: none + +# Clients allowed to connect (all for testing) +client pass { + from: 0.0.0.0/0 to: 0.0.0.0/0 + log: error +} + +# Rules for SOCKS requests +socks pass { + from: 0.0.0.0/0 to: 0.0.0.0/0 + protocol: tcp udp + method: none + log: error +} From ecb80f8b60863133ec11fbe8d0f6b3f1aabdfca1 Mon Sep 17 00:00:00 2001 From: sullis Date: Mon, 22 Sep 2025 09:05:12 -0700 Subject: [PATCH 19/23] remove unused maven property (#2115) remove unused property: netty.iouring --- pom.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/pom.xml b/pom.xml index 22b72aa2c..e8b98d86f 100644 --- a/pom.xml +++ b/pom.xml @@ -46,7 +46,6 @@ UTF-8 4.2.5.Final - 0.0.26.Final 1.18.0 2.0.16 1.5.7-2 From 9790ec57576060e26a23861d9bc28085efdd42f6 Mon Sep 17 00:00:00 2001 From: sullis Date: Sat, 4 Oct 2025 16:39:31 -0700 Subject: [PATCH 20/23] maven-compiler-plugin 3.14.1 (#2117) --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e8b98d86f..3643a4a39 100644 --- a/pom.xml +++ b/pom.xml @@ -293,7 +293,7 @@ org.apache.maven.plugins maven-compiler-plugin - 3.14.0 + 3.14.1 11 11 From f41b26f7d4d9d17d5708bbfee561c5468a3fbcf5 Mon Sep 17 00:00:00 2001 From: sullis Date: Sun, 26 Oct 2025 02:06:54 -0700 Subject: [PATCH 21/23] upgrade testcontainers to v2 (#2119) Release Notes https://github.com/testcontainers/testcontainers-java/releases --- client/pom.xml | 2 +- pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/pom.xml b/client/pom.xml index 019e294c0..fb386eabf 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -198,7 +198,7 @@ org.testcontainers - junit-jupiter + testcontainers-junit-jupiter ${testcontainers.version} test diff --git a/pom.xml b/pom.xml index 3643a4a39..4a26e4e49 100644 --- a/pom.xml +++ b/pom.xml @@ -52,7 +52,7 @@ 2.0.1 1.5.18 26.0.2 - 1.20.4 + 2.0.1 From 43826107728bde0443202af2908a65fa3fa79473 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Oct 2025 01:05:00 +0530 Subject: [PATCH 22/23] Bump org.apache.tomcat.embed:tomcat-embed-core from 10.1.44 to 10.1.47 in /client (#2120) Bumps org.apache.tomcat.embed:tomcat-embed-core from 10.1.44 to 10.1.47. [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.apache.tomcat.embed:tomcat-embed-core&package-manager=maven&previous-version=10.1.44&new-version=10.1.47)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/AsyncHttpClient/async-http-client/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- client/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pom.xml b/client/pom.xml index fb386eabf..1c0e4c64b 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -31,7 +31,7 @@ org.asynchttpclient.client 11.0.24 - 10.1.44 + 10.1.47 2.18.0 4.11.0 3.0 From b7e0319466624f8cfcde6a36b2087daef0d7a63b Mon Sep 17 00:00:00 2001 From: Aayush Atharva Date: Sat, 15 Nov 2025 02:49:32 +0530 Subject: [PATCH 23/23] Release v3.0.4 (#2126) --- README.md | 4 ++-- client/pom.xml | 2 +- pom.xml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 61621a682..318d58da2 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Maven: org.asynchttpclient async-http-client - 3.0.3 + 3.0.4 ``` @@ -28,7 +28,7 @@ Maven: Gradle: ```groovy dependencies { - implementation 'org.asynchttpclient:async-http-client:3.0.3' + implementation 'org.asynchttpclient:async-http-client:3.0.4' } ``` diff --git a/client/pom.xml b/client/pom.xml index 1c0e4c64b..f3804ca3c 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -19,7 +19,7 @@ org.asynchttpclient async-http-client-project - 3.0.3 + 3.0.4 4.0.0 diff --git a/pom.xml b/pom.xml index 4a26e4e49..95d773f35 100644 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ org.asynchttpclient async-http-client-project - 3.0.3 + 3.0.4 pom AHC/Project