diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index f4538d3c7..000000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-# To get started with Dependabot version updates, you'll need to specify which
-# package ecosystems to update and where the package manifests are located.
-# Please see the documentation for all configuration options:
-# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
-
-version: 2
-updates:
- - package-ecosystem: "maven"
- directories:
- - "/"
- schedule:
- interval: "daily"
- - package-ecosystem: "github-actions"
- directories:
- - "/"
- schedule:
- interval: "daily"
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/.github/workflows/release.yml b/.github/workflows/release.yml
index 4a462dc99..059d1640f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -31,13 +31,13 @@ jobs:
with:
servers: |
[{
- "id": "ossrh",
+ "id": "central",
"username": "${{ secrets.OSSRH_USERNAME }}",
"password": "${{ secrets.OSSRH_PASSWORD }}"
}]
- name: Import GPG
- uses: crazy-max/ghaction-import-gpg@v6.2.0
+ uses: crazy-max/ghaction-import-gpg@v6.3.0
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
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/README.md b/README.md
index 4ae651b75..318d58da2 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ Maven:
org.asynchttpclient
async-http-client
- 3.0.1
+ 3.0.4
```
@@ -28,7 +28,7 @@ Maven:
Gradle:
```groovy
dependencies {
- implementation 'org.asynchttpclient:async-http-client:3.0.1'
+ implementation 'org.asynchttpclient:async-http-client:3.0.4'
}
```
diff --git a/client/pom.xml b/client/pom.xml
index 58dcd0aad..f3804ca3c 100644
--- a/client/pom.xml
+++ b/client/pom.xml
@@ -19,7 +19,7 @@
org.asynchttpclient
async-http-client-project
- 3.0.1
+ 3.0.4
4.0.0
@@ -31,7 +31,7 @@
org.asynchttpclient.client
11.0.24
- 10.1.33
+ 10.1.47
2.18.0
4.11.0
3.0
@@ -51,7 +51,7 @@
commons-fileupload
commons-fileupload
- 1.5
+ 1.6.0
test
@@ -188,5 +188,88 @@
2.1.6
test
+
+
+
+ org.testcontainers
+ testcontainers
+ ${testcontainers.version}
+ test
+
+
+ org.testcontainers
+ 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/AsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java
index 12dc93d7d..216dc4ed6 100644
--- a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java
+++ b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java
@@ -375,6 +375,16 @@ 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.
+ */
+ 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 {
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/RequestBuilderBase.java b/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java
index dbc5e4144..29bbaa670 100644
--- a/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java
+++ b/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java
@@ -93,6 +93,30 @@ public abstract class RequestBuilderBase> {
protected @Nullable Charset charset;
protected ChannelPoolPartitioning channelPoolPartitioning = ChannelPoolPartitioning.PerHostChannelPoolPartitioning.INSTANCE;
protected NameResolver nameResolver = DEFAULT_NAME_RESOLVER;
+ protected boolean contentTypeLocked;
+
+ /**
+ * Mark the Content-Type header as explicitly set by the user. When locked, the
+ * Content-Type header will not be modified by the client (e.g., charset addition).
+ */
+ protected final void doContentTypeLock() {
+ this.contentTypeLocked = true;
+ }
+
+ /**
+ * Clear the Content-Type lock, allowing the client to modify the Content-Type header
+ * if needed (for example, to add charset when it was auto-generated).
+ */
+ protected final void resetContentTypeLock() {
+ this.contentTypeLocked = false;
+ }
+
+ /**
+ * Return whether the Content-Type header has been locked as explicitly set by the user.
+ */
+ protected final boolean isContentTypeLocked() {
+ return this.contentTypeLocked;
+ }
protected RequestBuilderBase(String method, boolean disableUrlEncoding) {
this(method, disableUrlEncoding, true);
@@ -116,6 +140,10 @@ protected RequestBuilderBase(Request prototype, boolean disableUrlEncoding, bool
localAddress = prototype.getLocalAddress();
headers = new DefaultHttpHeaders(validateHeaders);
headers.add(prototype.getHeaders());
+ // If prototype has Content-Type, consider it as explicitly set
+ if (headers.contains(CONTENT_TYPE)) {
+ doContentTypeLock();
+ }
if (isNonEmpty(prototype.getCookies())) {
cookies = new ArrayList<>(prototype.getCookies());
}
@@ -181,6 +209,7 @@ public T setVirtualHost(String virtualHost) {
*/
public T clearHeaders() {
headers.clear();
+ resetContentTypeLock();
return asDerivedType();
}
@@ -203,6 +232,9 @@ public T setHeader(CharSequence name, String value) {
*/
public T setHeader(CharSequence name, Object value) {
headers.set(name, value);
+ if (CONTENT_TYPE.contentEqualsIgnoreCase(name)) {
+ doContentTypeLock();
+ }
return asDerivedType();
}
@@ -215,6 +247,9 @@ public T setHeader(CharSequence name, Object value) {
*/
public T setHeader(CharSequence name, Iterable> values) {
headers.set(name, values);
+ if (CONTENT_TYPE.contentEqualsIgnoreCase(name)) {
+ doContentTypeLock();
+ }
return asDerivedType();
}
@@ -243,6 +278,9 @@ public T addHeader(CharSequence name, Object value) {
}
headers.add(name, value);
+ if (CONTENT_TYPE.contentEqualsIgnoreCase(name)) {
+ doContentTypeLock();
+ }
return asDerivedType();
}
@@ -256,6 +294,9 @@ public T addHeader(CharSequence name, Object value) {
*/
public T addHeader(CharSequence name, Iterable> values) {
headers.add(name, values);
+ if (CONTENT_TYPE.contentEqualsIgnoreCase(name)) {
+ doContentTypeLock();
+ }
return asDerivedType();
}
@@ -264,6 +305,9 @@ public T setHeaders(HttpHeaders headers) {
this.headers.clear();
} else {
this.headers = headers;
+ if (headers.contains(CONTENT_TYPE)) {
+ doContentTypeLock();
+ }
}
return asDerivedType();
}
@@ -278,7 +322,12 @@ public T setHeaders(HttpHeaders headers) {
public T setHeaders(Map extends CharSequence, ? extends Iterable>> headers) {
clearHeaders();
if (headers != null) {
- headers.forEach((name, values) -> this.headers.add(name, values));
+ headers.forEach((name, values) -> {
+ this.headers.add(name, values);
+ if (CONTENT_TYPE.contentEqualsIgnoreCase(name)) {
+ doContentTypeLock();
+ }
+ });
}
return asDerivedType();
}
@@ -293,7 +342,12 @@ public T setHeaders(Map extends CharSequence, ? extends Iterable>> headers)
public T setSingleHeaders(Map extends CharSequence, ?> headers) {
clearHeaders();
if (headers != null) {
- headers.forEach((name, value) -> this.headers.add(name, value));
+ headers.forEach((name, value) -> {
+ this.headers.add(name, value);
+ if (CONTENT_TYPE.contentEqualsIgnoreCase(name)) {
+ doContentTypeLock();
+ }
+ });
}
return asDerivedType();
}
@@ -634,7 +688,8 @@ private void updateCharset() {
String contentTypeHeader = headers.get(CONTENT_TYPE);
Charset contentTypeCharset = extractContentTypeCharsetAttribute(contentTypeHeader);
charset = withDefault(contentTypeCharset, withDefault(charset, UTF_8));
- if (contentTypeHeader != null && contentTypeHeader.regionMatches(true, 0, "text/", 0, 5) && contentTypeCharset == null) {
+ // Only add charset if Content-Type was not explicitly set by user
+ if (!isContentTypeLocked() && contentTypeHeader != null && contentTypeHeader.regionMatches(true, 0, "text/", 0, 5) && contentTypeCharset == null) {
// add explicit charset to content-type header
headers.set(CONTENT_TYPE, contentTypeHeader + "; charset=" + charset.name());
}
diff --git a/client/src/main/java/org/asynchttpclient/channel/ChannelPoolPartitioning.java b/client/src/main/java/org/asynchttpclient/channel/ChannelPoolPartitioning.java
index 324a4ce34..291d81844 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 {
@@ -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());
@@ -111,7 +111,8 @@ public String toString() {
", virtualHost=" + virtualHost +
", proxyHost=" + proxyHost +
", proxyPort=" + proxyPort +
- ", proxyType=" + proxyType;
+ ", proxyType=" + proxyType +
+ ")";
}
}
}
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);
}
}
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) {
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..8d13361ae 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;
@@ -164,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());
}
@@ -189,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();
}
@@ -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;
}
@@ -429,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);
@@ -486,6 +543,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);
}
@@ -554,4 +615,4 @@ public ClientStats getClientStats() {
public boolean isOpen() {
return channelPool.isOpen();
}
-}
+}
\ No newline at end of file
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/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java b/client/src/main/java/org/asynchttpclient/netty/channel/NettyConnectListener.java
index 719733f8a..a1d61177e 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;
@@ -97,11 +98,65 @@ public void onSuccess(Channel channel, InetSocketAddress remoteAddress) {
Request request = future.getTargetRequest();
Uri uri = request.getUri();
- timeoutsHolder.setResolvedRemoteAddress(remoteAddress);
+ // don't set a null resolved address - if the remoteAddress is null we keep
+ // the previously scheduled (possibly unresolved) address to avoid NPEs in
+ // timeout logging and keep useful diagnostic information
+ if (remoteAddress != null) {
+ 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/HttpHandler.java b/client/src/main/java/org/asynchttpclient/netty/handler/HttpHandler.java
index 06ec46a2b..99a23c7e9 100755
--- a/client/src/main/java/org/asynchttpclient/netty/handler/HttpHandler.java
+++ b/client/src/main/java/org/asynchttpclient/netty/handler/HttpHandler.java
@@ -21,6 +21,7 @@
import io.netty.handler.codec.DecoderResultProvider;
import io.netty.handler.codec.http.HttpContent;
import io.netty.handler.codec.http.HttpHeaders;
+import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpResponse;
import io.netty.handler.codec.http.LastHttpContent;
@@ -32,6 +33,7 @@
import org.asynchttpclient.netty.NettyResponseStatus;
import org.asynchttpclient.netty.channel.ChannelManager;
import org.asynchttpclient.netty.request.NettyRequestSender;
+import org.asynchttpclient.util.HttpConstants.ResponseStatusCodes;
import java.io.IOException;
import java.net.InetSocketAddress;
@@ -43,8 +45,11 @@ public HttpHandler(AsyncHttpClientConfig config, ChannelManager channelManager,
super(config, channelManager, requestSender);
}
- private static boolean abortAfterHandlingStatus(AsyncHandler> handler, NettyResponseStatus status) throws Exception {
- return handler.onStatusReceived(status) == State.ABORT;
+ private static boolean abortAfterHandlingStatus(AsyncHandler> handler, HttpMethod httpMethod, NettyResponseStatus status) throws Exception {
+ // For non-200 response of a CONNECT request, it's still unconnected.
+ // We need to either close the connection or reuse it but send CONNECT request again.
+ // The former one is easier or we have to attach more state to Channel.
+ return handler.onStatusReceived(status) == State.ABORT || httpMethod == HttpMethod.CONNECT && status.getStatusCode() != ResponseStatusCodes.OK_200;
}
private static boolean abortAfterHandlingHeaders(AsyncHandler> handler, HttpHeaders responseHeaders) throws Exception {
@@ -61,7 +66,7 @@ private void handleHttpResponse(final HttpResponse response, final Channel chann
HttpHeaders responseHeaders = response.headers();
if (!interceptors.exitAfterIntercept(channel, future, handler, response, status, responseHeaders)) {
- boolean abort = abortAfterHandlingStatus(handler, status) || abortAfterHandlingHeaders(handler, responseHeaders);
+ boolean abort = abortAfterHandlingStatus(handler, httpRequest.method(), status) || abortAfterHandlingHeaders(handler, responseHeaders);
if (abort) {
finishUpdate(future, channel, true);
}
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/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/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java b/client/src/main/java/org/asynchttpclient/netty/request/NettyRequestSender.java
index 9fff868b2..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;
@@ -97,6 +98,13 @@ public NettyRequestSender(AsyncHttpClientConfig config, ChannelManager channelMa
requestFactory = new NettyRequestFactory(config);
}
+ // needConnect returns true if the request is secure/websocket and a HTTP proxy is set
+ private boolean needConnect(final Request request, final ProxyServer proxyServer) {
+ return proxyServer != null
+ && proxyServer.getProxyType().isHttp()
+ && (request.getUri().isSecured() || request.getUri().isWebSocket());
+ }
+
public ListenableFuture sendRequest(final Request request, final AsyncHandler asyncHandler, NettyResponseFuture future) {
if (isClosed()) {
throw new IllegalStateException("Closed");
@@ -106,9 +114,7 @@ public ListenableFuture sendRequest(final Request request, final AsyncHan
ProxyServer proxyServer = getProxyServer(config, request);
// WebSockets use connect tunneling to work with proxies
- if (proxyServer != null && proxyServer.getProxyType().isHttp() &&
- (request.getUri().isSecured() || request.getUri().isWebSocket()) &&
- !isConnectAlreadyDone(request, future)) {
+ if (needConnect(request, proxyServer) && !isConnectAlreadyDone(request, future)) {
// Proxy with HTTPS or WebSocket: CONNECT for sure
if (future != null && future.isConnectAllowed()) {
// Perform CONNECT
@@ -125,6 +131,8 @@ public ListenableFuture sendRequest(final Request request, final AsyncHan
private static boolean isConnectAlreadyDone(Request request, NettyResponseFuture> future) {
return future != null
+ // If the channel can't be reused or closed, a CONNECT is still required
+ && future.isReuseChannel() && Channels.isChannelActive(future.channel())
&& future.getNettyRequest() != null
&& future.getNettyRequest().getHttpRequest().method() == HttpMethod.CONNECT
&& !request.getMethod().equals(CONNECT);
@@ -137,11 +145,19 @@ private static boolean isConnectAlreadyDone(Request request, NettyResponseFuture
*/
private ListenableFuture sendRequestWithCertainForceConnect(Request request, AsyncHandler asyncHandler, NettyResponseFuture future,
ProxyServer proxyServer, boolean performConnectRequest) {
- NettyResponseFuture newFuture = newNettyRequestAndResponseFuture(request, asyncHandler, future, proxyServer, performConnectRequest);
Channel channel = getOpenChannel(future, request, proxyServer, asyncHandler);
- return Channels.isChannelActive(channel)
- ? sendRequestWithOpenChannel(newFuture, asyncHandler, channel)
- : sendRequestWithNewChannel(request, proxyServer, newFuture, asyncHandler);
+ if (Channels.isChannelActive(channel)) {
+ NettyResponseFuture newFuture = newNettyRequestAndResponseFuture(request, asyncHandler, future,
+ proxyServer, performConnectRequest);
+ return sendRequestWithOpenChannel(newFuture, asyncHandler, channel);
+ } else {
+ // A new channel is not expected when performConnectRequest is false. We need to
+ // revisit the condition of sending
+ // the CONNECT request to the new channel.
+ NettyResponseFuture newFuture = newNettyRequestAndResponseFuture(request, asyncHandler, future,
+ proxyServer, needConnect(request, proxyServer));
+ return sendRequestWithNewChannel(request, proxyServer, newFuture, asyncHandler);
+ }
}
/**
@@ -322,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/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);
- }
}
}
diff --git a/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutTimerTask.java b/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutTimerTask.java
index 3c9a3675e..b7e678fa8 100755
--- a/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutTimerTask.java
+++ b/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutTimerTask.java
@@ -57,6 +57,28 @@ public void clean() {
void appendRemoteAddress(StringBuilder sb) {
InetSocketAddress remoteAddress = timeoutsHolder.remoteAddress();
+
+ // Guard against null remoteAddress which can happen when the TimeoutsHolder
+ // was created without an original remote address (for example when using a
+ // pooled channel whose remoteAddress() returned null). In that case fall
+ // back to the URI host/port from the request to avoid a NPE and provide
+ // a useful diagnostic.
+ if (remoteAddress == null) {
+ if (nettyResponseFuture != null && nettyResponseFuture.getTargetRequest() != null) {
+ try {
+ String host = nettyResponseFuture.getTargetRequest().getUri().getHost();
+ int port = nettyResponseFuture.getTargetRequest().getUri().getExplicitPort();
+ sb.append(host == null ? "unknown" : host);
+ sb.append(':').append(port);
+ } catch (Exception ignored) {
+ sb.append("unknown:0");
+ }
+ } else {
+ sb.append("unknown:0");
+ }
+ return;
+ }
+
sb.append(remoteAddress.getHostString());
if (!remoteAddress.isUnresolved()) {
sb.append('/').append(remoteAddress.getAddress().getHostAddress());
diff --git a/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java b/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java
index acce84b6d..93f6b26a2 100755
--- a/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java
+++ b/client/src/main/java/org/asynchttpclient/netty/timeout/TimeoutsHolder.java
@@ -110,6 +110,12 @@ public void cancel() {
}
private Timeout newTimeout(TimerTask task, long delay) {
- return requestSender.isClosed() ? null : nettyTimer.newTimeout(task, delay, TimeUnit.MILLISECONDS);
+ // requestSender or nettyTimer might be null in unit tests or in some edge
+ // cases where a channel's remote address wasn't available. In such cases
+ // avoid scheduling any timeouts rather than throwing a NPE.
+ if (requestSender == null || nettyTimer == null || requestSender.isClosed()) {
+ return null;
+ }
+ return nettyTimer.newTimeout(task, delay, TimeUnit.MILLISECONDS);
}
}
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/AutomaticDecompressionTest.java b/client/src/test/java/org/asynchttpclient/AutomaticDecompressionTest.java
index dfd0a9446..8f57ffb88 100644
--- a/client/src/test/java/org/asynchttpclient/AutomaticDecompressionTest.java
+++ b/client/src/test/java/org/asynchttpclient/AutomaticDecompressionTest.java
@@ -22,6 +22,7 @@
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
+import io.github.nettyplus.leakdetector.junit.NettyLeakDetectorExtension;
import io.netty.handler.codec.compression.Brotli;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
@@ -35,11 +36,13 @@
import java.util.List;
import java.util.stream.Collectors;
import java.util.zip.GZIPOutputStream;
+import org.junit.jupiter.api.extension.ExtendWith;
import static org.junit.jupiter.api.Assertions.assertEquals;
+@ExtendWith(NettyLeakDetectorExtension.class)
public class AutomaticDecompressionTest {
- private static final String UNCOMPRESSED_PAYLOAD = "a".repeat(500);
+ private static final String UNCOMPRESSED_PAYLOAD = "a".repeat(50_000);
private static HttpServer HTTP_SERVER;
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/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/RequestBuilderTest.java b/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java
index 34e79121d..2da2246d6 100644
--- a/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java
+++ b/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java
@@ -16,6 +16,8 @@
package org.asynchttpclient;
import io.github.artsok.RepeatedIfExceptionsTest;
+import io.netty.handler.codec.http.DefaultHttpHeaders;
+import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.cookie.Cookie;
import io.netty.handler.codec.http.cookie.DefaultCookie;
@@ -29,7 +31,9 @@
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Collections.singletonList;
import static org.asynchttpclient.Dsl.get;
+import static org.asynchttpclient.Dsl.post;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class RequestBuilderTest {
@@ -220,4 +224,192 @@ public void testSettingHeadersUsingMapWithStringKeys() {
Request request = requestBuilder.build();
assertEquals(request.getHeaders().get("X-Forwarded-For"), "10.0.0.1");
}
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testUserSetTextPlainContentTypeShouldNotBeModified() {
+ Request request = post("http://localhost/test")
+ .setHeader("Content-Type", "text/plain")
+ .setBody("Hello World")
+ .build();
+
+ String contentType = request.getHeaders().get("Content-Type");
+ assertEquals("text/plain", contentType, "Content-Type should not be modified when user explicitly sets it");
+ assertFalse(contentType.contains("charset"), "Charset should not be added to user-specified Content-Type");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testUserSetTextXmlContentTypeShouldNotBeModified() {
+ Request request = post("http://localhost/test")
+ .setHeader("Content-Type", "text/xml")
+ .setBody("Hello")
+ .build();
+
+ String contentType = request.getHeaders().get("Content-Type");
+ assertEquals("text/xml", contentType, "Content-Type should not be modified when user explicitly sets it");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testUserSetTextHtmlContentTypeShouldNotBeModified() {
+ Request request = post("http://localhost/test")
+ .setHeader("Content-Type", "text/html")
+ .setBody("")
+ .build();
+
+ String contentType = request.getHeaders().get("Content-Type");
+ assertEquals("text/html", contentType, "Content-Type should not be modified when user explicitly sets it");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testUserSetContentTypeWithCharsetShouldBePreserved() {
+ Request request = post("http://localhost/test")
+ .setHeader("Content-Type", "text/xml; charset=ISO-8859-1")
+ .setBody("Hello")
+ .build();
+
+ String contentType = request.getHeaders().get("Content-Type");
+ assertEquals("text/xml; charset=ISO-8859-1", contentType, "User-specified charset should be preserved");
+ assertTrue(contentType.contains("ISO-8859-1"), "ISO-8859-1 charset should be preserved");
+ assertFalse(contentType.contains("UTF-8"), "UTF-8 should not be added");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testApplicationJsonContentTypeShouldNotBeModified() {
+ Request request = post("http://localhost/test")
+ .setHeader("Content-Type", "application/json")
+ .setBody("{\"key\": \"value\"}")
+ .build();
+
+ String contentType = request.getHeaders().get("Content-Type");
+ assertEquals("application/json", contentType, "application/json should not be modified");
+ assertFalse(contentType.contains("charset"), "Charset should not be added to application/json");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testAddHeaderContentTypeShouldNotBeModified() {
+ Request request = post("http://localhost/test")
+ .addHeader("Content-Type", "text/plain")
+ .setBody("Hello World")
+ .build();
+
+ String contentType = request.getHeaders().get("Content-Type");
+ assertEquals("text/plain", contentType, "Content-Type set via addHeader should not be modified");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testSetHeadersWithHttpHeadersShouldLockContentType() {
+ HttpHeaders httpHeaders = new DefaultHttpHeaders();
+ httpHeaders.set("Content-Type", "text/plain");
+
+ Request request = post("http://localhost/test")
+ .setHeaders(httpHeaders)
+ .setBody("Hello World")
+ .build();
+
+ String contentType = request.getHeaders().get("Content-Type");
+ assertEquals("text/plain", contentType, "Content-Type set via setHeaders(HttpHeaders) should not be modified");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testSetHeadersWithMapShouldLockContentType() {
+ Map> headerMap = new HashMap<>();
+ headerMap.put("Content-Type", singletonList("text/plain"));
+
+ Request request = post("http://localhost/test")
+ .setHeaders(headerMap)
+ .setBody("Hello World")
+ .build();
+
+ String contentType = request.getHeaders().get("Content-Type");
+ assertEquals("text/plain", contentType, "Content-Type set via setHeaders(Map) should not be modified");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testSetSingleHeadersShouldLockContentType() {
+ Map headerMap = new HashMap<>();
+ headerMap.put("Content-Type", "text/plain");
+
+ Request request = post("http://localhost/test")
+ .setSingleHeaders(headerMap)
+ .setBody("Hello World")
+ .build();
+
+ String contentType = request.getHeaders().get("Content-Type");
+ assertEquals("text/plain", contentType, "Content-Type set via setSingleHeaders should not be modified");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testClearHeadersShouldResetContentTypeLock() {
+ Request request = post("http://localhost/test")
+ .setHeader("Content-Type", "text/plain")
+ .clearHeaders()
+ .setHeader("Content-Type", "text/xml")
+ .setBody("")
+ .build();
+
+ String contentType = request.getHeaders().get("Content-Type");
+ assertEquals("text/xml", contentType, "Content-Type should still be preserved after clear and re-set");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testPrototypeRequestShouldPreserveContentType() {
+ Request original = post("http://localhost/test")
+ .setHeader("Content-Type", "text/plain")
+ .setBody("Hello")
+ .build();
+
+ Request copy = post("http://localhost/test")
+ .setUrl(original.getUri().toUrl())
+ .setHeaders(original.getHeaders())
+ .setBody("Hello")
+ .build();
+
+ String contentType = copy.getHeaders().get("Content-Type");
+ assertEquals("text/plain", contentType, "Content-Type should be preserved from prototype");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testRequestBuilderFromPrototypeShouldPreserveContentType() {
+ Request original = post("http://localhost/test")
+ .setHeader("Content-Type", "text/plain")
+ .setBody("Hello")
+ .build();
+
+ Request copy = new RequestBuilder(original).build();
+
+ String contentType = copy.getHeaders().get("Content-Type");
+ assertEquals("text/plain", contentType, "Content-Type should be preserved from prototype via RequestBuilder");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testCaseInsensitiveContentTypeHeader() {
+ Request request = post("http://localhost/test")
+ .setHeader("content-type", "text/plain")
+ .setBody("Hello World")
+ .build();
+
+ String contentType = request.getHeaders().get("Content-Type");
+ assertEquals("text/plain", contentType, "Content-Type should be matched case-insensitively");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testSetHeaderWithIterableShouldLockContentType() {
+ Request request = post("http://localhost/test")
+ .setHeader("Content-Type", singletonList("text/plain"))
+ .setBody("Hello World")
+ .build();
+
+ String contentType = request.getHeaders().get("Content-Type");
+ assertEquals("text/plain", contentType, "Content-Type set via setHeader(Iterable) should not be modified");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testAddHeaderWithIterableShouldLockContentType() {
+ Request request = post("http://localhost/test")
+ .addHeader("Content-Type", singletonList("text/plain"))
+ .setBody("Hello World")
+ .build();
+
+ String contentType = request.getHeaders().get("Content-Type");
+ assertEquals("text/plain", contentType, "Content-Type set via addHeader(Iterable) should not be modified");
+ }
}
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");
+ }
+ }
+}
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/java/org/asynchttpclient/netty/timeout/TimeoutTimerTaskTest.java b/client/src/test/java/org/asynchttpclient/netty/timeout/TimeoutTimerTaskTest.java
new file mode 100644
index 000000000..2a5f5e205
--- /dev/null
+++ b/client/src/test/java/org/asynchttpclient/netty/timeout/TimeoutTimerTaskTest.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2014-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.netty.timeout;
+
+import org.asynchttpclient.AsyncCompletionHandler;
+import org.asynchttpclient.DefaultAsyncHttpClientConfig;
+import org.asynchttpclient.Request;
+import org.asynchttpclient.RequestBuilder;
+import org.asynchttpclient.channel.ChannelPoolPartitioning;
+import org.asynchttpclient.netty.NettyResponseFuture;
+import org.junit.jupiter.api.Test;
+
+import java.net.InetSocketAddress;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class TimeoutTimerTaskTest {
+
+ @Test
+ public void appendRemoteAddressShouldNotThrowWhenRemoteAddressIsNull() {
+ Request request = new RequestBuilder().setUrl("http://example.com:12345").build();
+ NettyResponseFuture> future = new NettyResponseFuture<>(request, new AsyncCompletionHandler