diff --git a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java
index 78a8a086b..38724e1aa 100644
--- a/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java
+++ b/client/src/main/java/org/asynchttpclient/AsyncHttpClientConfig.java
@@ -184,6 +184,48 @@ default boolean isUseAbsoluteRequestDeadline() {
*/
int getMaxRedirects();
+ /**
+ * Whether to refuse a redirect that leaves an {@code https} or {@code wss} exchange for an unsecured
+ * scheme. The redirected request is never sent; the exchange fails with a
+ * {@link org.asynchttpclient.handler.RedirectRefusedException}.
+ *
+ * Applies to every hop, bodies or not, and is judged against the scheme of the request sent on that hop,
+ * so a cleartext hop has no downgrade to refuse but a later secured one in the same exchange still does.
+ *
+ * {@link org.asynchttpclient.Request#getRefuseSchemeDowngradeOnRedirect()} can refuse a hop this allows,
+ * but cannot permit one this refuses. An implementation that wraps another configuration has to forward
+ * this, or the operator's setting on the wrapped one is lost to the {@code false} default here.
+ *
+ * @return true to refuse such a hop, false to follow it
+ */
+ default boolean isRefuseSchemeDowngradeOnRedirect() {
+ return false;
+ }
+
+ /**
+ * Whether to refuse a redirect that would resend this request's content to a different origin. It applies
+ * only to a hop that keeps the body and only when the request carries content, so a bodyless cross-origin
+ * hop and a same-origin hop are untouched, while a {@code GET} carrying content is refused like any
+ * other. Moving the same host onto TLS is exempt, keeping the port or moving between the two scheme
+ * defaults. The exempted hop keeps the host, not the path: the target path and query are the server's
+ * choice.
+ *
+ * The hop is refused rather than stripped of its body, because a {@code PUT} arriving empty would
+ * overwrite the target with nothing. Weigh that against what it blocks: an upload answered with a
+ * redirect to another host stops working.
+ *
+ * The hop is still not same-base, so credentials are stripped from it as they always were.
+ *
+ * {@link org.asynchttpclient.Request#getRefuseCrossOriginBodyOnRedirect()} can refuse a hop this allows,
+ * but cannot permit one this refuses. An implementation that wraps another configuration has to forward
+ * this, or the operator's setting on the wrapped one is lost to the {@code false} default here.
+ *
+ * @return true to refuse such a hop, false to follow it
+ */
+ default boolean isRefuseCrossOriginBodyOnRedirect() {
+ return false;
+ }
+
/**
* Is the {@link ChannelPool} support enabled.
*
diff --git a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java
index a35f5505a..9b37c8d7b 100644
--- a/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java
+++ b/client/src/main/java/org/asynchttpclient/DefaultAsyncHttpClientConfig.java
@@ -84,6 +84,8 @@
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultMaxRequestRetry;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultPooledConnectionIdleTimeout;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultReadTimeout;
+import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultRefuseCrossOriginBodyOnRedirect;
+import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultRefuseSchemeDowngradeOnRedirect;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultLoadBalance;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultRequestTimeout;
import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.defaultShutdownQuietPeriod;
@@ -131,6 +133,8 @@ public class DefaultAsyncHttpClientConfig implements AsyncHttpClientConfig {
private final boolean followRedirect;
private final int maxRedirects;
private final boolean strict302Handling;
+ private final boolean refuseSchemeDowngradeOnRedirect;
+ private final boolean refuseCrossOriginBodyOnRedirect;
private final boolean compressionEnforced;
private final boolean enableAutomaticDecompression;
@@ -239,6 +243,8 @@ private DefaultAsyncHttpClientConfig(// http
boolean followRedirect,
int maxRedirects,
boolean strict302Handling,
+ boolean refuseSchemeDowngradeOnRedirect,
+ boolean refuseCrossOriginBodyOnRedirect,
boolean compressionEnforced,
boolean enableAutomaticDecompression,
long maxDecompressedResponseSize,
@@ -346,6 +352,8 @@ private DefaultAsyncHttpClientConfig(// http
this.followRedirect = followRedirect;
this.maxRedirects = maxRedirects;
this.strict302Handling = strict302Handling;
+ this.refuseSchemeDowngradeOnRedirect = refuseSchemeDowngradeOnRedirect;
+ this.refuseCrossOriginBodyOnRedirect = refuseCrossOriginBodyOnRedirect;
this.compressionEnforced = compressionEnforced;
this.enableAutomaticDecompression = enableAutomaticDecompression;
this.maxDecompressedResponseSize = maxDecompressedResponseSize;
@@ -499,6 +507,16 @@ public boolean isStrict302Handling() {
return strict302Handling;
}
+ @Override
+ public boolean isRefuseSchemeDowngradeOnRedirect() {
+ return refuseSchemeDowngradeOnRedirect;
+ }
+
+ @Override
+ public boolean isRefuseCrossOriginBodyOnRedirect() {
+ return refuseCrossOriginBodyOnRedirect;
+ }
+
@Override
public boolean isCompressionEnforced() {
return compressionEnforced;
@@ -960,6 +978,8 @@ public static class Builder {
private boolean followRedirect = defaultFollowRedirect();
private int maxRedirects = defaultMaxRedirects();
private boolean strict302Handling = defaultStrict302Handling();
+ private boolean refuseSchemeDowngradeOnRedirect = defaultRefuseSchemeDowngradeOnRedirect();
+ private boolean refuseCrossOriginBodyOnRedirect = defaultRefuseCrossOriginBodyOnRedirect();
private boolean compressionEnforced = defaultCompressionEnforced();
private boolean enableAutomaticDecompression = defaultEnableAutomaticDecompression();
private long maxDecompressedResponseSize = defaultMaxDecompressedResponseSize();
@@ -1070,6 +1090,8 @@ public Builder(AsyncHttpClientConfig config) {
followRedirect = config.isFollowRedirect();
maxRedirects = config.getMaxRedirects();
strict302Handling = config.isStrict302Handling();
+ refuseSchemeDowngradeOnRedirect = config.isRefuseSchemeDowngradeOnRedirect();
+ refuseCrossOriginBodyOnRedirect = config.isRefuseCrossOriginBodyOnRedirect();
compressionEnforced = config.isCompressionEnforced();
enableAutomaticDecompression = config.isEnableAutomaticDecompression();
maxDecompressedResponseSize = config.getMaxDecompressedResponseSize();
@@ -1193,6 +1215,16 @@ public Builder setStrict302Handling(final boolean strict302Handling) {
return this;
}
+ public Builder setRefuseSchemeDowngradeOnRedirect(final boolean refuseSchemeDowngradeOnRedirect) {
+ this.refuseSchemeDowngradeOnRedirect = refuseSchemeDowngradeOnRedirect;
+ return this;
+ }
+
+ public Builder setRefuseCrossOriginBodyOnRedirect(final boolean refuseCrossOriginBodyOnRedirect) {
+ this.refuseCrossOriginBodyOnRedirect = refuseCrossOriginBodyOnRedirect;
+ return this;
+ }
+
/**
* If true, AHC will add Accept-Encoding HTTP header to each request
*
@@ -1800,6 +1832,8 @@ public DefaultAsyncHttpClientConfig build() {
followRedirect,
maxRedirects,
strict302Handling,
+ refuseSchemeDowngradeOnRedirect,
+ refuseCrossOriginBodyOnRedirect,
compressionEnforced,
enableAutomaticDecompression,
maxDecompressedResponseSize,
diff --git a/client/src/main/java/org/asynchttpclient/DefaultRequest.java b/client/src/main/java/org/asynchttpclient/DefaultRequest.java
index 3a4885601..7df31fb95 100644
--- a/client/src/main/java/org/asynchttpclient/DefaultRequest.java
+++ b/client/src/main/java/org/asynchttpclient/DefaultRequest.java
@@ -64,6 +64,8 @@ public class DefaultRequest implements Request {
private final @Nullable File file;
private final @Nullable Boolean followRedirect;
private final @Nullable Boolean useAbsoluteRequestDeadline;
+ private final @Nullable Boolean refuseSchemeDowngradeOnRedirect;
+ private final @Nullable Boolean refuseCrossOriginBodyOnRedirect;
private final Duration requestTimeout;
private final Duration readTimeout;
private final long rangeOffset;
@@ -103,7 +105,7 @@ public DefaultRequest(String method,
this(method, uri, address, localAddress, headers, cookies, byteData, compositeByteData, stringData,
byteBufferData, byteBufData, streamData, bodyGenerator, formParams, bodyParts, virtualHost,
proxyServer, realm, file, followRedirect, requestTimeout, readTimeout, rangeOffset, charset,
- channelPoolPartitioning, nameResolver, null);
+ channelPoolPartitioning, nameResolver, null, null, null);
}
/**
@@ -114,6 +116,10 @@ public DefaultRequest(String method,
*
* @param useAbsoluteRequestDeadline whether {@code requestTimeout} bounds the whole exchange rather than
* each attempt within it, or null to defer to the client config
+ * @param refuseSchemeDowngradeOnRedirect whether to refuse a redirect to an unsecured scheme, or null to
+ * defer to the client config
+ * @param refuseCrossOriginBodyOnRedirect whether to refuse a redirect that resends the content to another
+ * origin, or null to defer to the client config
*/
DefaultRequest(String method,
Uri uri,
@@ -141,7 +147,9 @@ public DefaultRequest(String method,
@Nullable Charset charset,
ChannelPoolPartitioning channelPoolPartitioning,
NameResolver nameResolver,
- @Nullable Boolean useAbsoluteRequestDeadline) {
+ @Nullable Boolean useAbsoluteRequestDeadline,
+ @Nullable Boolean refuseSchemeDowngradeOnRedirect,
+ @Nullable Boolean refuseCrossOriginBodyOnRedirect) {
this.method = method;
this.uri = uri;
this.address = address;
@@ -163,6 +171,8 @@ public DefaultRequest(String method,
this.file = file;
this.followRedirect = followRedirect;
this.useAbsoluteRequestDeadline = useAbsoluteRequestDeadline;
+ this.refuseSchemeDowngradeOnRedirect = refuseSchemeDowngradeOnRedirect;
+ this.refuseCrossOriginBodyOnRedirect = refuseCrossOriginBodyOnRedirect;
this.requestTimeout = requestTimeout == null ? Duration.ZERO : requestTimeout;
this.readTimeout = readTimeout == null ? Duration.ZERO : readTimeout;
this.rangeOffset = rangeOffset;
@@ -281,6 +291,16 @@ public List getBodyParts() {
return useAbsoluteRequestDeadline;
}
+ @Override
+ public @Nullable Boolean getRefuseSchemeDowngradeOnRedirect() {
+ return refuseSchemeDowngradeOnRedirect;
+ }
+
+ @Override
+ public @Nullable Boolean getRefuseCrossOriginBodyOnRedirect() {
+ return refuseCrossOriginBodyOnRedirect;
+ }
+
@Override
public Duration getRequestTimeout() {
return requestTimeout;
diff --git a/client/src/main/java/org/asynchttpclient/Request.java b/client/src/main/java/org/asynchttpclient/Request.java
index 1ec85fd62..e16b47ddc 100644
--- a/client/src/main/java/org/asynchttpclient/Request.java
+++ b/client/src/main/java/org/asynchttpclient/Request.java
@@ -183,6 +183,34 @@ default Boolean getUseAbsoluteRequestDeadline() {
return null;
}
+ /**
+ * Refuses a scheme downgrade on this request and the hops it leads to, even when
+ * {@link AsyncHttpClientConfig#isRefuseSchemeDowngradeOnRedirect()} is off. Tightening only, so
+ * {@code false} behaves as null and cannot re-enable a hop the client configuration refuses. A filter
+ * that builds a fresh request rather than deriving one from {@link #toBuilder()} drops it, so the
+ * client-wide option is what holds a posture across replays.
+ *
+ * @return true to refuse, or null or false to use the config value
+ */
+ @Nullable
+ default Boolean getRefuseSchemeDowngradeOnRedirect() {
+ return null;
+ }
+
+ /**
+ * Refuses a cross-origin body replay on this request and the hops it leads to, even when
+ * {@link AsyncHttpClientConfig#isRefuseCrossOriginBodyOnRedirect()} is off. Tightening only, so
+ * {@code false} behaves as null and cannot re-enable a hop the client configuration refuses. A filter
+ * that builds a fresh request rather than deriving one from {@link #toBuilder()} drops it, so the
+ * client-wide option is what holds a posture across replays.
+ *
+ * @return true to refuse, or null or false to use the config value
+ */
+ @Nullable
+ default Boolean getRefuseCrossOriginBodyOnRedirect() {
+ return null;
+ }
+
/**
* @return the request timeout. Non zero values means "override config value".
*/
diff --git a/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java b/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java
index 42a5b28b2..b6d32008e 100644
--- a/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java
+++ b/client/src/main/java/org/asynchttpclient/RequestBuilderBase.java
@@ -88,6 +88,8 @@ public abstract class RequestBuilderBase> {
protected @Nullable File file;
protected @Nullable Boolean followRedirect;
protected @Nullable Boolean useAbsoluteRequestDeadline;
+ protected @Nullable Boolean refuseSchemeDowngradeOnRedirect;
+ protected @Nullable Boolean refuseCrossOriginBodyOnRedirect;
protected @Nullable Duration requestTimeout;
protected @Nullable Duration readTimeout;
protected long rangeOffset;
@@ -167,6 +169,8 @@ protected RequestBuilderBase(Request prototype, boolean disableUrlEncoding, bool
file = prototype.getFile();
followRedirect = prototype.getFollowRedirect();
useAbsoluteRequestDeadline = prototype.getUseAbsoluteRequestDeadline();
+ refuseSchemeDowngradeOnRedirect = prototype.getRefuseSchemeDowngradeOnRedirect();
+ refuseCrossOriginBodyOnRedirect = prototype.getRefuseCrossOriginBodyOnRedirect();
requestTimeout = prototype.getRequestTimeout();
readTimeout = prototype.getReadTimeout();
rangeOffset = prototype.getRangeOffset();
@@ -611,6 +615,34 @@ public T setUseAbsoluteRequestDeadline(boolean useAbsoluteRequestDeadline) {
return asDerivedType();
}
+ /**
+ * Refuses a scheme downgrade on this request even when the client allows it.
+ *
+ * @param refuseSchemeDowngradeOnRedirect true to refuse a redirect off this request that leaves a secured
+ * scheme for one that is not; false leaves
+ * {@link AsyncHttpClientConfig#isRefuseSchemeDowngradeOnRedirect()}
+ * in charge and cannot relax it
+ * @return {@code this}
+ */
+ public T setRefuseSchemeDowngradeOnRedirect(boolean refuseSchemeDowngradeOnRedirect) {
+ this.refuseSchemeDowngradeOnRedirect = refuseSchemeDowngradeOnRedirect;
+ return asDerivedType();
+ }
+
+ /**
+ * Refuses a cross-origin body replay on this request even when the client allows it.
+ *
+ * @param refuseCrossOriginBodyOnRedirect true to refuse a redirect off this request that would resend its
+ * content to another origin; false leaves
+ * {@link AsyncHttpClientConfig#isRefuseCrossOriginBodyOnRedirect()}
+ * in charge and cannot relax it
+ * @return {@code this}
+ */
+ public T setRefuseCrossOriginBodyOnRedirect(boolean refuseCrossOriginBodyOnRedirect) {
+ this.refuseCrossOriginBodyOnRedirect = refuseCrossOriginBodyOnRedirect;
+ return asDerivedType();
+ }
+
public T setFollowRedirect(boolean followRedirect) {
this.followRedirect = followRedirect;
return asDerivedType();
@@ -699,6 +731,8 @@ private RequestBuilderBase> executeSignatureCalculator() {
rb.file = file;
rb.followRedirect = followRedirect;
rb.useAbsoluteRequestDeadline = useAbsoluteRequestDeadline;
+ rb.refuseSchemeDowngradeOnRedirect = refuseSchemeDowngradeOnRedirect;
+ rb.refuseCrossOriginBodyOnRedirect = refuseCrossOriginBodyOnRedirect;
rb.requestTimeout = requestTimeout;
rb.readTimeout = readTimeout;
rb.rangeOffset = rangeOffset;
@@ -771,6 +805,8 @@ public Request build() {
rb.charset,
rb.channelPoolPartitioning,
rb.nameResolver,
- rb.useAbsoluteRequestDeadline);
+ rb.useAbsoluteRequestDeadline,
+ rb.refuseSchemeDowngradeOnRedirect,
+ rb.refuseCrossOriginBodyOnRedirect);
}
}
diff --git a/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java b/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java
index 25778dee9..7154d2480 100644
--- a/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java
+++ b/client/src/main/java/org/asynchttpclient/config/AsyncHttpClientConfigDefaults.java
@@ -62,6 +62,8 @@ public final class AsyncHttpClientConfigDefaults {
public static final String KEEP_ALIVE_CONFIG = "keepAlive";
public static final String MAX_REQUEST_RETRY_CONFIG = "maxRequestRetry";
public static final String LOAD_BALANCE_CONFIG = "loadBalance";
+ public static final String REFUSE_SCHEME_DOWNGRADE_ON_REDIRECT_CONFIG = "refuseSchemeDowngradeOnRedirect";
+ public static final String REFUSE_CROSS_ORIGIN_BODY_ON_REDIRECT_CONFIG = "refuseCrossOriginBodyOnRedirect";
public static final String FAILED_IP_COOLDOWN_ENABLED_CONFIG = "failedIpCooldownEnabled";
public static final String USE_ABSOLUTE_REQUEST_DEADLINE_CONFIG = "useAbsoluteRequestDeadline";
public static final String FAILED_IP_COOLDOWN_PERIOD_CONFIG = "failedIpCooldownPeriod";
@@ -408,6 +410,14 @@ public static boolean defaultHttp2CleartextEnabled() {
return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getBoolean(ASYNC_CLIENT_CONFIG_ROOT + HTTP2_CLEARTEXT_ENABLED_CONFIG);
}
+ public static boolean defaultRefuseSchemeDowngradeOnRedirect() {
+ return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getBoolean(ASYNC_CLIENT_CONFIG_ROOT + REFUSE_SCHEME_DOWNGRADE_ON_REDIRECT_CONFIG);
+ }
+
+ public static boolean defaultRefuseCrossOriginBodyOnRedirect() {
+ return AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getBoolean(ASYNC_CLIENT_CONFIG_ROOT + REFUSE_CROSS_ORIGIN_BODY_ON_REDIRECT_CONFIG);
+ }
+
public static LoadBalance defaultLoadBalance() {
String value = AsyncHttpClientConfigHelper.getAsyncHttpClientConfig().getString(ASYNC_CLIENT_CONFIG_ROOT + LOAD_BALANCE_CONFIG);
if (value == null || value.trim().isEmpty()) {
diff --git a/client/src/main/java/org/asynchttpclient/handler/RedirectRefusedException.java b/client/src/main/java/org/asynchttpclient/handler/RedirectRefusedException.java
new file mode 100644
index 000000000..7fc5db043
--- /dev/null
+++ b/client/src/main/java/org/asynchttpclient/handler/RedirectRefusedException.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.handler;
+
+import org.asynchttpclient.AsyncHttpClientConfig;
+import org.asynchttpclient.uri.Uri;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Thrown when a redirect was refused rather than followed, by
+ * {@link AsyncHttpClientConfig#isRefuseSchemeDowngradeOnRedirect()} or
+ * {@link AsyncHttpClientConfig#isRefuseCrossOriginBodyOnRedirect()}. The redirected request was never sent.
+ *
+ * When the origin answers the 3xx before the request content has finished writing, the failed write can
+ * surface first and the exchange fails with that {@link java.io.IOException} instead. The content still never
+ * reaches the new origin, but a caller branching on this type alone will miss that case.
+ *
+ *
Deliberately not an {@link java.io.IOException}: both handlers gate their {@code IOExceptionFilter}
+ * replay on that type, so a refusal that was one would come back as several more attempts to send the content
+ * the caller asked to send once. {@link MaxRedirectException} avoids it for the same reason.
+ *
+ *
Whether re-sending by hand is safe depends on {@link #getStatusCode()}. Every refusal except a 303,
+ * and except a 301 or non-strict 302 on a {@code POST}, preserved both the method and the content, so the
+ * same request can be aimed somewhere else unchanged. A 303 is intended as an indirect response to a
+ * request the origin has very likely already applied (RFC 9110 section 15.4.4), and no status proves
+ * otherwise, so a non-idempotent method still needs the caller's judgement (section 9.2.2).
+ */
+public final class RedirectRefusedException extends Exception {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Which gate refused the redirect. Constants are only ever appended, so a caller switching on this must
+ * still handle a value it does not know.
+ */
+ public enum Reason {
+
+ /** The hop left a secured scheme for one that is not. */
+ SCHEME_DOWNGRADE("the target scheme is not secured"),
+
+ /** The hop would have resent the request content to an origin the caller never addressed. */
+ CROSS_ORIGIN_BODY("it would resend the request content to another origin");
+
+ // A field, not a switch: a constant added later cannot compile without a description.
+ private final String description;
+
+ Reason(String description) {
+ this.description = description;
+ }
+ }
+
+ private final Reason reason;
+ private final int statusCode;
+ // Uri is not Serializable. getMessage(), getReason() and getStatusCode() do survive.
+ private final transient @Nullable Uri sourceUri;
+ private final transient @Nullable Uri targetUri;
+
+ /**
+ * @param reason which gate refused the redirect
+ * @param statusCode the status code of the redirect response that was refused
+ * @param sourceUri the URI this hop was leaving
+ * @param targetUri the redirect target that was refused
+ */
+ public RedirectRefusedException(Reason reason, int statusCode, Uri sourceUri, Uri targetUri) {
+ // No stack trace: the frames would all be Netty's event loop, and the caller's ExecutionException
+ // already carries theirs.
+ super(message(reason, statusCode, sourceUri, targetUri), null, true, false);
+ this.reason = reason;
+ this.statusCode = statusCode;
+ this.sourceUri = sourceUri;
+ this.targetUri = targetUri;
+ }
+
+ // getBaseUrl() on both sides: toBaseUrl() keeps the path and toString() keeps userinfo, neither of which
+ // belongs in a log. Built here rather than by the caller so a second refusal site cannot reintroduce
+ // that, and eagerly rather than in getMessage() so it survives serialization.
+ private static String message(Reason reason, int statusCode, Uri sourceUri, Uri targetUri) {
+ return "Refusing to follow the " + statusCode + " redirect from " + sourceUri.getBaseUrl()
+ + " to " + targetUri.getBaseUrl() + ": " + reason.description;
+ }
+
+ /**
+ * @return which gate refused the redirect
+ */
+ public Reason getReason() {
+ return reason;
+ }
+
+ /**
+ * @return the status code of the redirect response that was refused
+ */
+ public int getStatusCode() {
+ return statusCode;
+ }
+
+ /**
+ * The URI this hop was leaving, which is the request actually sent on this leg rather than the one the
+ * caller built. Prefer {@link Uri#getBaseUrl()} when logging it, as {@link #getMessage()} already does.
+ *
+ * @return the URI the refused redirect was leaving, or null on an instance that was deserialized
+ */
+ public @Nullable Uri getSourceUri() {
+ return sourceUri;
+ }
+
+ /**
+ * The refused target. Its path, query and userinfo are server-chosen, so prefer {@link Uri#getBaseUrl()}
+ * when logging it, as {@link #getMessage()} already does.
+ *
+ * @return the refused redirect target, or null on an instance that was deserialized
+ */
+ public @Nullable Uri getTargetUri() {
+ return targetUri;
+ }
+}
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 24ff03407..2f6058af7 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
@@ -22,6 +22,7 @@
import io.netty.handler.codec.http.HttpUtil;
import io.netty.handler.codec.http.cookie.Cookie;
import io.netty.handler.codec.http2.Http2StreamChannel;
+import io.netty.util.AsciiString;
import org.asynchttpclient.AsyncHttpClientConfig;
import org.asynchttpclient.Realm;
import org.asynchttpclient.Realm.AuthScheme;
@@ -29,6 +30,8 @@
import org.asynchttpclient.RequestBuilder;
import org.asynchttpclient.cookie.CookieStore;
import org.asynchttpclient.handler.MaxRedirectException;
+import org.asynchttpclient.handler.RedirectRefusedException;
+import org.asynchttpclient.handler.RedirectRefusedException.Reason;
import org.asynchttpclient.netty.NettyResponseFuture;
import org.asynchttpclient.netty.channel.ChannelManager;
import org.asynchttpclient.netty.channel.PrincipalScopedPartitionKey;
@@ -54,6 +57,10 @@
import static io.netty.handler.codec.http.HttpHeaderNames.HOST;
import static io.netty.handler.codec.http.HttpHeaderNames.LOCATION;
import static io.netty.handler.codec.http.HttpHeaderNames.PROXY_AUTHORIZATION;
+import static org.asynchttpclient.uri.Uri.HTTP;
+import static org.asynchttpclient.uri.Uri.HTTPS;
+import static org.asynchttpclient.uri.Uri.WS;
+import static org.asynchttpclient.uri.Uri.WSS;
import static org.asynchttpclient.util.HttpConstants.Methods.GET;
import static org.asynchttpclient.util.HttpConstants.Methods.HEAD;
import static org.asynchttpclient.util.HttpConstants.Methods.OPTIONS;
@@ -94,12 +101,16 @@ static boolean isRedirect(int statusCode) {
private final NettyRequestSender requestSender;
private final MaxRedirectException maxRedirectException;
private final boolean stripAuthorizationOnRedirect;
+ private final boolean refuseSchemeDowngradeOnRedirect;
+ private final boolean refuseCrossOriginBodyOnRedirect;
Redirect30xInterceptor(ChannelManager channelManager, AsyncHttpClientConfig config, NettyRequestSender requestSender) {
this.channelManager = channelManager;
this.config = config;
this.requestSender = requestSender;
stripAuthorizationOnRedirect = config.isStripAuthorizationOnRedirect(); // New flag
+ refuseSchemeDowngradeOnRedirect = config.isRefuseSchemeDowngradeOnRedirect();
+ refuseCrossOriginBodyOnRedirect = config.isRefuseCrossOriginBodyOnRedirect();
maxRedirectException = unknownStackTrace(new MaxRedirectException("Maximum redirect reached: " + config.getMaxRedirects()),
Redirect30xInterceptor.class, "exitAfterHandlingRedirect");
}
@@ -132,22 +143,42 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture>
HttpHeaders responseHeaders = response.headers();
String location = responseHeaders.get(LOCATION);
- Uri newUri = Uri.create(future.getUri(), location);
- LOGGER.debug("Redirecting to {}", newUri);
+ // Location resolves against the target URI of the request actually sent on this leg
+ // (RFC 9110 section 15.4, modification 1), and the gates below must judge that same URI.
+ // A 401 or 407 retry leaves the future's own target behind, so do not read it here.
+ Uri currentUri = request.getUri();
+ Uri newUri = Uri.create(currentUri, location);
+
+ boolean sameBase = currentUri.isSameBase(newUri);
+ boolean schemeDowngrade = currentUri.isSecured() && !newUri.isSecured();
+
+ // Refuse before ensureBodyReplayable, whose IOException an IOExceptionFilter would replay.
+ if (schemeDowngrade && refuseSchemeDowngrade(request)) {
+ throw new RedirectRefusedException(Reason.SCHEME_DOWNGRADE, statusCode, currentUri, newUri);
+ }
+ BodyRepresentation bodyRepresentation =
+ keepBody ? selectedBodyRepresentation(request) : BodyRepresentation.NONE;
+ if (keepBody && refuseCrossOriginBody(request)
+ && !sameOrigin(currentUri, newUri) && !secureUpgrade(currentUri, newUri)
+ && bodyRepresentation != BodyRepresentation.NONE) {
+ throw new RedirectRefusedException(Reason.CROSS_ORIGIN_BODY, statusCode, currentUri, newUri);
+ }
+
+ if (LOGGER.isDebugEnabled()) {
+ LOGGER.debug("Redirecting to {}", newUri.toUrlWithoutUserInfo());
+ }
- boolean sameBase = request.getUri().isSameBase(newUri);
- boolean schemeDowngrade = request.getUri().isSecured() && !newUri.isSecured();
boolean stripAuth = !sameBase || schemeDowngrade || stripAuthorizationOnRedirect;
- if (stripAuth && (request.getRealm() != null
+ if (LOGGER.isDebugEnabled() && stripAuth && (request.getRealm() != null
|| request.getHeaders().contains(AUTHORIZATION)
|| request.getHeaders().contains(COOKIE))) {
- LOGGER.debug("Stripping credentials on redirect to {}", newUri);
+ LOGGER.debug("Stripping credentials on redirect to {}", newUri.toUrlWithoutUserInfo());
}
final RequestBuilder requestBuilder;
if (keepBody) {
- ensureBodyReplayable(request, future.isStreamConsumed());
+ ensureBodyReplayable(request, bodyRepresentation, future.isStreamConsumed());
requestBuilder = request.toBuilder();
if (!sameBase) {
// An explicitly resolved address and virtual host belong to the previous target.
@@ -179,6 +210,17 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture>
requestBuilder.setUseAbsoluteRequestDeadline(useAbsoluteRequestDeadline);
}
+ // The !keepBody branch builds from an empty builder, so carry these forward or the caller's
+ // choice lapses from the second hop on.
+ Boolean refuseSchemeDowngrade = request.getRefuseSchemeDowngradeOnRedirect();
+ if (refuseSchemeDowngrade != null) {
+ requestBuilder.setRefuseSchemeDowngradeOnRedirect(refuseSchemeDowngrade);
+ }
+ Boolean refuseCrossOriginBody = request.getRefuseCrossOriginBodyOnRedirect();
+ if (refuseCrossOriginBody != null) {
+ requestBuilder.setRefuseCrossOriginBodyOnRedirect(refuseCrossOriginBody);
+ }
+
if (stripAuth) {
future.setRealm(null);
future.setProxyRealm(null);
@@ -211,7 +253,9 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture>
final Request nextRequest = requestBuilder.setUri(newUri).build();
future.setTargetRequest(nextRequest);
- LOGGER.debug("Sending redirect to {}", newUri);
+ if (LOGGER.isDebugEnabled()) {
+ LOGGER.debug("Sending redirect to {}", newUri.toUrlWithoutUserInfo());
+ }
if (channel instanceof Http2StreamChannel) {
// HTTP/2 stream channels are single-use and close immediately after the response.
@@ -240,8 +284,59 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture>
return false;
}
- private static void ensureBodyReplayable(Request request, boolean streamConsumed) throws IOException {
- BodyRepresentation bodyRepresentation = selectedBodyRepresentation(request);
+ // Tightening only, unlike followRedirect: these are security switches, and a framework layer that builds
+ // the Request should not be able to void a posture the operator set on the client.
+ private boolean refuseSchemeDowngrade(Request request) {
+ return refuseSchemeDowngradeOnRedirect || Boolean.TRUE.equals(request.getRefuseSchemeDowngradeOnRedirect());
+ }
+
+ private boolean refuseCrossOriginBody(Request request) {
+ return refuseCrossOriginBodyOnRedirect || Boolean.TRUE.equals(request.getRefuseCrossOriginBodyOnRedirect());
+ }
+
+ /**
+ * Same scheme, host and effective port, per RFC 6454 section 4. Not {@link Uri#isSameBase(Uri)}, which
+ * compares hosts with {@link String#equals}. Hosts fold ASCII-only, the {@code i;ascii-casemap} collation
+ * that step 5 of that section asks for: {@link String#equalsIgnoreCase}
+ * folds Unicode and would call {@code i.example} equal to a host starting {@code U+0130}, a different
+ * host. Nothing here does IDNA either, so a Unicode host and its A-label read as different origins,
+ * erring towards refusal.
+ */
+ static boolean sameOrigin(Uri from, Uri to) {
+ return from.getScheme().equals(to.getScheme())
+ && AsciiString.contentEqualsIgnoreCase(from.getHost(), to.getHost())
+ && from.getExplicitPort() == to.getExplicitPort();
+ }
+
+ /**
+ * Whether this hop only swaps the same host onto TLS. A deliberate exemption from the origin rule rather
+ * than something RFC 6454 allows, because refusing a move onto TLS would cost confidentiality rather than
+ * protect it. The host is compared, not trusted; the path and query are not compared at all, so the
+ * content lands wherever the redirect says on that host. The port must be unchanged or both sides at
+ * their scheme's default, so {@code :8080} to {@code :9999} is a different endpoint and stays refused.
+ * That is modelled on the port mapping in RFC 6797 section 8.3 but looser than it: 8.3 is scoped to a
+ * Known HSTS Host and we keep no HSTS state, and it would have mapped an explicit {@code :80} to
+ * {@code :443} where this accepts {@code :80} to {@code :80}, which is the endpoint the redirect was
+ * served from.
+ *
+ * Only the content arm consults this; the hop is still cross-base, so credentials are stripped as usual
+ * and cookies are re-derived from the {@link org.asynchttpclient.cookie.CookieStore} against the new URI
+ * - which on an upgraded hop includes the host's {@code Secure} ones.
+ */
+ static boolean secureUpgrade(Uri from, Uri to) {
+ // Named pairs, not isSecured(), which would also admit http to wss. Schemes compare exactly because
+ // the Uri constructor lowercases them on every construction path.
+ boolean upgraded = HTTP.equals(from.getScheme()) && HTTPS.equals(to.getScheme())
+ || WS.equals(from.getScheme()) && WSS.equals(to.getScheme());
+ return upgraded
+ && AsciiString.contentEqualsIgnoreCase(from.getHost(), to.getHost())
+ && (from.getExplicitPort() == to.getExplicitPort()
+ || from.getExplicitPort() == from.getSchemeDefaultPort()
+ && to.getExplicitPort() == to.getSchemeDefaultPort());
+ }
+
+ private static void ensureBodyReplayable(Request request, BodyRepresentation bodyRepresentation,
+ boolean streamConsumed) throws IOException {
if (bodyRepresentation == BodyRepresentation.BODY_PARTS) {
for (Part part : request.getBodyParts()) {
if (part instanceof InputStreamPart) {
diff --git a/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties b/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties
index 1179088ca..55d14bd2a 100644
--- a/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties
+++ b/client/src/main/resources/org/asynchttpclient/config/ahc-default.properties
@@ -23,6 +23,8 @@ org.asynchttpclient.useProxyProperties=false
org.asynchttpclient.validateResponseHeaders=true
org.asynchttpclient.aggregateWebSocketFrameFragments=true
org.asynchttpclient.strict302Handling=false
+org.asynchttpclient.refuseSchemeDowngradeOnRedirect=false
+org.asynchttpclient.refuseCrossOriginBodyOnRedirect=false
org.asynchttpclient.keepAlive=true
org.asynchttpclient.maxRequestRetry=5
org.asynchttpclient.loadBalance=DEFAULT
diff --git a/client/src/test/java/org/asynchttpclient/AsyncHttpClientDefaultsTest.java b/client/src/test/java/org/asynchttpclient/AsyncHttpClientDefaultsTest.java
index 39c7bab8f..34056a818 100644
--- a/client/src/test/java/org/asynchttpclient/AsyncHttpClientDefaultsTest.java
+++ b/client/src/test/java/org/asynchttpclient/AsyncHttpClientDefaultsTest.java
@@ -132,6 +132,18 @@ public void testDefaultStrict302Handling() {
testBooleanSystemProperty("strict302Handling", "defaultStrict302Handling", "true");
}
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testDefaultRefuseSchemeDowngradeOnRedirect() {
+ assertFalse(AsyncHttpClientConfigDefaults.defaultRefuseSchemeDowngradeOnRedirect());
+ testBooleanSystemProperty("refuseSchemeDowngradeOnRedirect", "defaultRefuseSchemeDowngradeOnRedirect", "true");
+ }
+
+ @RepeatedIfExceptionsTest(repeats = 5)
+ public void testDefaultRefuseCrossOriginBodyOnRedirect() {
+ assertFalse(AsyncHttpClientConfigDefaults.defaultRefuseCrossOriginBodyOnRedirect());
+ testBooleanSystemProperty("refuseCrossOriginBodyOnRedirect", "defaultRefuseCrossOriginBodyOnRedirect", "true");
+ }
+
@RepeatedIfExceptionsTest(repeats = 5)
public void testDefaultAllowPoolingConnection() {
assertTrue(AsyncHttpClientConfigDefaults.defaultKeepAlive());
diff --git a/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java b/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java
index bcb8336f6..c61f6a916 100644
--- a/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java
+++ b/client/src/test/java/org/asynchttpclient/BasicHttp2Test.java
@@ -16,6 +16,7 @@
package org.asynchttpclient;
import io.netty.bootstrap.ServerBootstrap;
+import io.github.nettyplus.leakdetector.junit.NettyLeakDetectorExtension;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
@@ -37,6 +38,7 @@
import io.netty.handler.codec.http2.DefaultHttp2ResetFrame;
import io.netty.handler.codec.http2.Http2DataFrame;
import io.netty.handler.codec.http2.Http2Error;
+import io.netty.handler.codec.http2.Http2FrameCodec;
import io.netty.handler.codec.http2.Http2FrameCodecBuilder;
import io.netty.util.ReferenceCountUtil;
import io.netty.handler.codec.http2.Http2Headers;
@@ -45,19 +47,23 @@
import io.netty.handler.codec.http2.Http2StreamChannel;
import io.netty.handler.ssl.ApplicationProtocolConfig;
import io.netty.handler.ssl.ApplicationProtocolNames;
+import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.pkitesting.CertificateBuilder;
import io.netty.pkitesting.X509Bundle;
import io.netty.util.concurrent.GlobalEventExecutor;
+import org.asynchttpclient.handler.RedirectRefusedException;
import org.asynchttpclient.proxy.ProxyServer;
import org.asynchttpclient.proxy.ProxyType;
import org.asynchttpclient.test.EventCollectingHandler;
import org.eclipse.jetty.proxy.ConnectHandler;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.ServerConnector;
+import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.Test;
import java.net.URLDecoder;
@@ -109,6 +115,7 @@
*
Falls back to HTTP/1.1 when HTTP/2 is disabled
*
*/
+@ExtendWith(NettyLeakDetectorExtension.class)
public class BasicHttp2Test {
// Event constants (from HttpTest/EventCollectingHandler)
@@ -217,6 +224,18 @@ private void routeRequest(ChannelHandlerContext ctx, ByteBuf body) {
}
ctx.write(new DefaultHttp2HeadersFrame(responseHeaders, true));
ctx.flush();
+ } else if (routePath.equals("/downgrade")) {
+ ReferenceCountUtil.safeRelease(body);
+ Http2Headers responseHeaders = new DefaultHttp2Headers().status("302");
+ responseHeaders.add("location", "http://127.0.0.1:1/target");
+ ctx.write(new DefaultHttp2HeadersFrame(responseHeaders, true));
+ ctx.flush();
+ } else if (routePath.equals("/cross-origin")) {
+ ReferenceCountUtil.safeRelease(body);
+ Http2Headers responseHeaders = new DefaultHttp2Headers().status("307");
+ responseHeaders.add("location", "https://127.0.0.1:1/target");
+ ctx.write(new DefaultHttp2HeadersFrame(responseHeaders, true));
+ ctx.flush();
} else if (routePath.equals("/head")) {
ReferenceCountUtil.safeRelease(body);
Http2Headers responseHeaders = new DefaultHttp2Headers()
@@ -380,13 +399,24 @@ protected void initChannel(Channel ch) {
serverChildChannels.add(ch);
ch.pipeline()
.addLast("ssl", serverSslCtx.newHandler(ch.alloc()))
- .addLast(Http2FrameCodecBuilder.forServer().build())
- .addLast(new Http2MultiplexHandler(new ChannelInitializer() {
+ .addLast(new ApplicationProtocolNegotiationHandler(ApplicationProtocolNames.HTTP_1_1) {
@Override
- protected void initChannel(Http2StreamChannel streamCh) {
- streamCh.pipeline().addLast(new Http2TestServerHandler());
+ protected void configurePipeline(ChannelHandlerContext ctx, String protocol) {
+ if (!ApplicationProtocolNames.HTTP_2.equals(protocol)) {
+ throw new IllegalStateException("unexpected ALPN protocol: " + protocol);
+ }
+ ctx.pipeline()
+ .addLast(Http2FrameCodecBuilder.forServer().build())
+ .addLast(new Http2MultiplexHandler(
+ new ChannelInitializer() {
+ @Override
+ protected void initChannel(Http2StreamChannel streamCh) {
+ streamCh.pipeline()
+ .addLast(new Http2TestServerHandler());
+ }
+ }));
}
- }));
+ });
}
});
@@ -408,6 +438,15 @@ public void stopServer() throws InterruptedException {
ReferenceCountUtil.release(serverSslCtx);
}
+ private @Nullable Channel negotiatedHttp2Channel() {
+ for (Channel child : serverChildChannels) {
+ if (child.pipeline().get(Http2FrameCodec.class) != null) {
+ return child;
+ }
+ }
+ return null;
+ }
+
private String httpsUrl(String path) {
return "https://localhost:" + serverPort + path;
}
@@ -690,17 +729,19 @@ public void http2RoundRobinGoawayReleasesPermitForReplacementConnection() throws
// Long-running stream that keeps its connection (and, before the fix, the only permit) busy.
client.executeRequest(org.asynchttpclient.Dsl.get(httpsUrl("/delay/30000")).setNameResolver(resolver));
- // Wait until the server has accepted the connection.
+ // Wait for a connection that has settled on HTTP/2; one that has only been accepted has no
+ // codec yet and cannot encode the GOAWAY below.
long deadline = System.currentTimeMillis() + 5000;
- while (serverChildChannels.size() < 1 && System.currentTimeMillis() < deadline) {
+ Channel parent = negotiatedHttp2Channel();
+ while (parent == null && System.currentTimeMillis() < deadline) {
Thread.sleep(20);
+ parent = negotiatedHttp2Channel();
}
+ assertNotNull(parent, "no HTTP/2 connection settled within 5s");
assertEquals(1, serverChildChannels.size(), "exactly one HTTP/2 connection should be established");
- Thread.sleep(300);
// GOAWAY with a high lastStreamId leaves the in-flight stream running, so the connection
// stays open and draining.
- Channel parent = serverChildChannels.iterator().next();
parent.writeAndFlush(new io.netty.handler.codec.http2.DefaultHttp2GoAwayFrame(Http2Error.NO_ERROR)
.setExtraStreamIds(1000)).sync();
Thread.sleep(300);
@@ -1573,4 +1614,30 @@ public void http2SequentialRequestsWithMaxConnectionsPerHostOne() throws Excepti
}
}
}
+
+ /**
+ * Http2Handler gates its own IOExceptionFilter replay on the exception type, so both arms are pinned.
+ */
+ @Test
+ public void schemeDowngradeRefusalAppliesOverHttp2() throws Exception {
+ try (AsyncHttpClient client = http2ClientWithConfig(builder -> builder
+ .setFollowRedirect(true)
+ .setRefuseSchemeDowngradeOnRedirect(true))) {
+ ExecutionException failure = assertThrows(ExecutionException.class,
+ () -> client.prepareGet(httpsUrl("/downgrade")).execute().get(10, TimeUnit.SECONDS));
+ assertInstanceOf(RedirectRefusedException.class, failure.getCause());
+ }
+ }
+
+ @Test
+ public void crossOriginBodyRefusalAppliesOverHttp2() throws Exception {
+ try (AsyncHttpClient client = http2ClientWithConfig(builder -> builder
+ .setFollowRedirect(true)
+ .setRefuseCrossOriginBodyOnRedirect(true))) {
+ ExecutionException failure = assertThrows(ExecutionException.class,
+ () -> client.preparePut(httpsUrl("/cross-origin")).setBody("payload")
+ .execute().get(10, TimeUnit.SECONDS));
+ assertInstanceOf(RedirectRefusedException.class, failure.getCause());
+ }
+ }
}
diff --git a/client/src/test/java/org/asynchttpclient/RedirectRefusalConfigTest.java b/client/src/test/java/org/asynchttpclient/RedirectRefusalConfigTest.java
new file mode 100644
index 000000000..5653664ab
--- /dev/null
+++ b/client/src/test/java/org/asynchttpclient/RedirectRefusalConfigTest.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient;
+
+import org.asynchttpclient.config.AsyncHttpClientConfigHelper;
+import org.junit.jupiter.api.Test;
+
+import static org.asynchttpclient.Dsl.config;
+import static org.asynchttpclient.config.AsyncHttpClientConfigDefaults.ASYNC_CLIENT_CONFIG_ROOT;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class RedirectRefusalConfigTest {
+
+ @Test
+ public void bothArmsAreOffByDefault() {
+ AsyncHttpClientConfig built = config().build();
+ assertFalse(built.isRefuseSchemeDowngradeOnRedirect());
+ assertFalse(built.isRefuseCrossOriginBodyOnRedirect());
+ }
+
+ @Test
+ public void theBuilderSetsEachArmOnItsOwn() {
+ AsyncHttpClientConfig downgradeOnly = config().setRefuseSchemeDowngradeOnRedirect(true).build();
+ assertTrue(downgradeOnly.isRefuseSchemeDowngradeOnRedirect());
+ assertFalse(downgradeOnly.isRefuseCrossOriginBodyOnRedirect());
+
+ AsyncHttpClientConfig bodyOnly = config().setRefuseCrossOriginBodyOnRedirect(true).build();
+ assertFalse(bodyOnly.isRefuseSchemeDowngradeOnRedirect());
+ assertTrue(bodyOnly.isRefuseCrossOriginBodyOnRedirect());
+ }
+
+ /**
+ * The Builder's field initialisers are the only wiring between the properties and a built config, and
+ * nothing else here would notice losing them: an uninitialised boolean is false, which is the default.
+ */
+ @Test
+ public void thePropertiesReachABuiltConfig() {
+ String downgrade = ASYNC_CLIENT_CONFIG_ROOT + "refuseSchemeDowngradeOnRedirect";
+ String crossOrigin = ASYNC_CLIENT_CONFIG_ROOT + "refuseCrossOriginBodyOnRedirect";
+ System.setProperty(downgrade, "true");
+ System.setProperty(crossOrigin, "true");
+ AsyncHttpClientConfigHelper.reloadProperties();
+ try {
+ AsyncHttpClientConfig built = config().build();
+ assertTrue(built.isRefuseSchemeDowngradeOnRedirect());
+ assertTrue(built.isRefuseCrossOriginBodyOnRedirect());
+ } finally {
+ System.clearProperty(downgrade);
+ System.clearProperty(crossOrigin);
+ AsyncHttpClientConfigHelper.reloadProperties();
+ }
+ }
+
+ @Test
+ public void theCopyConstructorKeepsBoth() {
+ AsyncHttpClientConfig original = config()
+ .setRefuseSchemeDowngradeOnRedirect(true)
+ .setRefuseCrossOriginBodyOnRedirect(true)
+ .build();
+
+ AsyncHttpClientConfig copy = new DefaultAsyncHttpClientConfig.Builder(original).build();
+ assertTrue(copy.isRefuseSchemeDowngradeOnRedirect());
+ assertTrue(copy.isRefuseCrossOriginBodyOnRedirect());
+ }
+}
diff --git a/client/src/test/java/org/asynchttpclient/RedirectRefusalTest.java b/client/src/test/java/org/asynchttpclient/RedirectRefusalTest.java
new file mode 100644
index 000000000..55799b4c9
--- /dev/null
+++ b/client/src/test/java/org/asynchttpclient/RedirectRefusalTest.java
@@ -0,0 +1,553 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient;
+
+import io.netty.buffer.Unpooled;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.asynchttpclient.filter.FilterContext;
+import org.asynchttpclient.filter.IOExceptionFilter;
+import org.asynchttpclient.handler.MaxRedirectException;
+import org.asynchttpclient.handler.RedirectRefusedException;
+import org.asynchttpclient.uri.Uri;
+import org.asynchttpclient.request.body.generator.FileBodyGenerator;
+import org.asynchttpclient.request.body.generator.InputStreamBodyGenerator;
+import org.asynchttpclient.request.body.multipart.StringPart;
+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.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static java.util.Collections.singletonList;
+import static java.util.Objects.requireNonNull;
+import static org.asynchttpclient.Dsl.asyncHttpClient;
+import static org.asynchttpclient.Dsl.config;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The redirect refusal gate in {@code Redirect30xInterceptor}.
+ */
+public class RedirectRefusalTest extends AbstractBasicTest {
+
+ private final AtomicBoolean targetHit = new AtomicBoolean();
+ private final AtomicReference methodOnTarget = new AtomicReference<>();
+ private final AtomicReference bodyOnTarget = new AtomicReference<>();
+
+ @Override
+ @BeforeAll
+ public void setUpGlobal() throws Exception {
+ server = new Server();
+ ServerConnector plain = addHttpConnector(server);
+ ServerConnector secure = addHttpsConnector(server);
+ server.setHandler(new RedirectHandler());
+ server.start();
+ port1 = plain.getLocalPort();
+ port2 = secure.getLocalPort();
+ }
+
+ @BeforeEach
+ public void resetCaptures() {
+ targetHit.set(false);
+ methodOnTarget.set(null);
+ bodyOnTarget.set(null);
+ }
+
+ // ---------------------------------------------------------------- scheme downgrade
+
+ @Test
+ public void downgradeIsFollowedByDefault() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig())) {
+ Response response = client.prepareGet(secure("/downgrade")).execute().get(TIMEOUT, TimeUnit.SECONDS);
+ assertEquals(200, response.getStatusCode());
+ assertTrue(targetHit.get());
+ }
+ }
+
+ @Test
+ public void downgradeOnBodylessGetIsRefused() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig().setRefuseSchemeDowngradeOnRedirect(true))) {
+ RedirectRefusedException refusal = expectRefusal(client.prepareGet(secure("/downgrade")));
+ assertEquals(307, refusal.getStatusCode());
+ assertEquals(RedirectRefusedException.Reason.SCHEME_DOWNGRADE, refusal.getReason());
+ assertEquals("https", requireNonNull(refusal.getSourceUri()).getScheme());
+ assertEquals("http", requireNonNull(refusal.getTargetUri()).getScheme());
+ assertFalse(targetHit.get(), "the refused target must not be reached");
+ }
+ }
+
+ @Test
+ public void downgradeOnPutWithBodyIsRefused() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig().setRefuseSchemeDowngradeOnRedirect(true))) {
+ expectRefusal(client.preparePut(secure("/downgrade")).setBody("payload"));
+ assertFalse(targetHit.get());
+ }
+ }
+
+ @Test
+ public void theDowngradeArmNeverRefusesAnUpgrade() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig().setRefuseSchemeDowngradeOnRedirect(true))) {
+ Response response = client.preparePut(plain("/upgrade")).setBody("payload")
+ .execute().get(TIMEOUT, TimeUnit.SECONDS);
+ assertEquals(200, response.getStatusCode());
+ assertEquals("payload", bodyOnTarget.get());
+ }
+ }
+
+ /**
+ * The exempted upgrade keeps its port, so the hop leaves the gate and the client goes on to attempt TLS
+ * against the plaintext connector, which fails. That failure is the point: it can only be reached by not
+ * refusing. Nothing here proves the content arrives, which would need a listener on the same port.
+ */
+ @Test
+ public void anUpgradeThatKeepsItsPortIsNotRefused() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig().setRefuseCrossOriginBodyOnRedirect(true))) {
+ ExecutionException failure = assertThrows(ExecutionException.class,
+ () -> client.preparePut(plain("/upgrade-same-port")).setBody("payload")
+ .execute().get(TIMEOUT, TimeUnit.SECONDS));
+ assertInstanceOf(IOException.class, failure.getCause(),
+ "expected the TLS attempt against the plaintext connector to fail, got: " + failure.getCause());
+ assertFalse(failure.getCause() instanceof RedirectRefusedException,
+ "the same-host upgrade should have been exempt, but was refused: " + failure.getCause());
+ }
+ }
+
+ // The two connectors are on unrelated ephemeral ports, so this is a port change, not an upgrade.
+ @Test
+ public void anUpgradeThatAlsoChangesThePortIsStillRefused() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig().setRefuseCrossOriginBodyOnRedirect(true))) {
+ expectRefusal(client.preparePut(plain("/upgrade")).setBody("payload"));
+ assertFalse(targetHit.get());
+ }
+ }
+
+ @Test
+ public void refusalDeliversNoResponseToTheHandler() throws Exception {
+ AtomicReference completed = new AtomicReference<>();
+ AtomicReference failed = new AtomicReference<>();
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig().setRefuseSchemeDowngradeOnRedirect(true))) {
+ assertThrows(ExecutionException.class, () -> client.prepareGet(secure("/downgrade"))
+ .execute(new AsyncCompletionHandler() {
+ @Override
+ public Response onCompleted(Response response) {
+ completed.set(response);
+ return response;
+ }
+
+ @Override
+ public void onThrowable(Throwable t) {
+ failed.set(t);
+ }
+ }).get(TIMEOUT, TimeUnit.SECONDS));
+
+ assertNull(completed.get(), "a refused hop must not hand the 3xx back as the result");
+ assertInstanceOf(RedirectRefusedException.class, failed.get());
+ }
+ }
+
+ // ---------------------------------------------------------------- cross-origin body
+
+ @Test
+ public void crossOriginBodyReplayIsRefused() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig().setRefuseCrossOriginBodyOnRedirect(true))) {
+ RedirectRefusedException refusal = expectRefusal(client.preparePut(plain("/cross-origin")).setBody("payload"));
+ assertEquals(307, refusal.getStatusCode());
+ assertEquals(RedirectRefusedException.Reason.CROSS_ORIGIN_BODY, refusal.getReason());
+ assertFalse(targetHit.get(), "the content must not reach the other origin");
+ }
+ }
+
+ /**
+ * 301 and 302 keep method and content here, because the legacy rewrite is limited to POST.
+ */
+ @ParameterizedTest
+ @ValueSource(ints = {301, 302, 308})
+ public void crossOriginBodyReplayIsRefusedOnEveryBodyPreservingStatus(int statusCode) throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig().setRefuseCrossOriginBodyOnRedirect(true))) {
+ RedirectRefusedException refusal =
+ expectRefusal(client.preparePut(plain("/cross-origin-" + statusCode)).setBody("payload"));
+ assertEquals(statusCode, refusal.getStatusCode());
+ assertFalse(targetHit.get());
+ }
+ }
+
+ /**
+ * The gate reads the request rather than the wire, so each body representation has to be recognised.
+ */
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("bodyRepresentations")
+ public void crossOriginBodyReplayIsRefusedForEveryRepresentation(String name, Consumer body)
+ throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig().setRefuseCrossOriginBodyOnRedirect(true))) {
+ BoundRequestBuilder request = client.preparePut(plain("/cross-origin"));
+ body.accept(request);
+ expectRefusal(request);
+ assertFalse(targetHit.get(), name + " reached the other origin");
+ }
+ }
+
+ /**
+ * Getting the refusal rather than ensureBodyReplayable's IOException is what proves the gate runs first.
+ */
+ @Test
+ public void aConsumedStreamIsRefusedRatherThanFailingReplayable() throws Exception {
+ AtomicInteger filterCalls = new AtomicInteger();
+ DefaultAsyncHttpClientConfig.Builder builder = followingConfig()
+ .setRefuseCrossOriginBodyOnRedirect(true)
+ .addIOExceptionFilter(new IOExceptionFilter() {
+ @Override
+ public FilterContext filter(FilterContext ctx) {
+ filterCalls.incrementAndGet();
+ return ctx;
+ }
+ });
+
+ try (AsyncHttpClient client = asyncHttpClient(builder)) {
+ expectRefusal(client.preparePut(plain("/cross-origin")).setBody(nonResettable("payload")));
+ assertEquals(0, filterCalls.get());
+ assertFalse(targetHit.get());
+ }
+ }
+
+ private static Stream bodyRepresentations() throws IOException {
+ byte[] payload = "payload".getBytes(StandardCharsets.UTF_8);
+ File file = File.createTempFile("ahc-redirect-refusal", ".txt");
+ file.deleteOnExit();
+ Files.write(file.toPath(), payload);
+
+ return Stream.of(
+ Arguments.of("byteData", (Consumer) rb -> rb.setBody(payload)),
+ Arguments.of("stringData", (Consumer) rb -> rb.setBody("payload")),
+ Arguments.of("byteBufferData", (Consumer) rb -> rb.setBody(ByteBuffer.wrap(payload))),
+ Arguments.of("byteBufData", (Consumer) rb -> rb.setBody(Unpooled.wrappedBuffer(payload))),
+ Arguments.of("streamData", (Consumer) rb -> rb.setBody(new ByteArrayInputStream(payload))),
+ Arguments.of("file", (Consumer) rb -> rb.setBody(file)),
+ Arguments.of("formParams", (Consumer) rb -> rb.setFormParams(singletonList(new Param("k", "v")))),
+ Arguments.of("bodyParts", (Consumer) rb -> rb.addBodyPart(new StringPart("k", "v"))),
+ Arguments.of("compositeByteData", (Consumer) rb ->
+ rb.setBody(singletonList(payload))),
+ Arguments.of("fileBodyGenerator", (Consumer) rb ->
+ rb.setBody(new FileBodyGenerator(file))),
+ Arguments.of("bodyGenerator", (Consumer) rb ->
+ rb.setBody(new InputStreamBodyGenerator(new ByteArrayInputStream(payload)))));
+ }
+
+ private static InputStream nonResettable(String content) {
+ return new FilterInputStream(new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8))) {
+ @Override
+ public boolean markSupported() {
+ return false;
+ }
+ };
+ }
+
+ /**
+ * keepBody is true for every GET on a 301/302/307/308, so a gate that keyed on it alone would refuse most
+ * of the redirects on the web.
+ */
+ @Test
+ public void crossOriginBodylessGetIsFollowed() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig().setRefuseCrossOriginBodyOnRedirect(true))) {
+ Response response = client.prepareGet(plain("/cross-origin")).execute().get(TIMEOUT, TimeUnit.SECONDS);
+ assertEquals(200, response.getStatusCode());
+ assertTrue(targetHit.get());
+ }
+ }
+
+ @Test
+ public void crossOriginPostRewrittenToGetIsFollowed() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(strictConfig())) {
+ Response response = client.preparePost(plain("/see-other")).setBody("payload")
+ .execute().get(TIMEOUT, TimeUnit.SECONDS);
+ assertEquals(200, response.getStatusCode());
+ assertEquals("GET", methodOnTarget.get());
+ assertEquals("", bodyOnTarget.get());
+ }
+ }
+
+ @Test
+ public void sameOriginBodyHopIsFollowed() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(strictConfig())) {
+ Response response = client.preparePut(plain("/same-origin")).setBody("payload")
+ .execute().get(TIMEOUT, TimeUnit.SECONDS);
+ assertEquals(200, response.getStatusCode());
+ assertEquals("payload", bodyOnTarget.get());
+ }
+ }
+
+ @Test
+ public void theTwoArmsAreIndependent() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig().setRefuseSchemeDowngradeOnRedirect(true))) {
+ Response response = client.preparePut(plain("/cross-origin")).setBody("payload")
+ .execute().get(TIMEOUT, TimeUnit.SECONDS);
+ assertEquals(200, response.getStatusCode(), "the downgrade arm must not refuse a cross-origin body");
+ }
+ }
+
+ // ---------------------------------------------------------------- how the refusal terminates
+
+ /**
+ * A refusal that was an IOException would come back as further attempts to send the content.
+ */
+ @Test
+ public void refusalIsNotAnIOExceptionAndIsNotReplayed() throws Exception {
+ AtomicInteger filterCalls = new AtomicInteger();
+ DefaultAsyncHttpClientConfig.Builder builder = followingConfig()
+ .setRefuseSchemeDowngradeOnRedirect(true)
+ .addIOExceptionFilter(new IOExceptionFilter() {
+ @Override
+ public FilterContext filter(FilterContext ctx) {
+ filterCalls.incrementAndGet();
+ return ctx;
+ }
+ });
+
+ try (AsyncHttpClient client = asyncHttpClient(builder)) {
+ Throwable cause = expectRefusal(client.prepareGet(secure("/downgrade")));
+ assertFalse(cause instanceof IOException);
+ assertEquals(0, filterCalls.get(), "a refusal must not enter the IOExceptionFilter chain");
+ }
+ }
+
+ @Test
+ public void theRedirectBudgetIsCheckedFirst() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig()
+ .setMaxRedirects(1)
+ .setRefuseSchemeDowngradeOnRedirect(true))) {
+ ExecutionException failure = assertThrows(ExecutionException.class,
+ () -> client.prepareGet(secure("/downgrade")).execute().get(TIMEOUT, TimeUnit.SECONDS));
+ assertInstanceOf(MaxRedirectException.class, failure.getCause());
+ }
+ }
+
+ @Test
+ public void theMessageCarriesNeitherPathQueryNorUserInfo() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig().setRefuseSchemeDowngradeOnRedirect(true))) {
+ RedirectRefusedException refusal = expectRefusal(
+ client.prepareGet("https://user:pw@localhost:" + port2 + "/downgrade?token=CALLER_SECRET"));
+
+ String message = refusal.getMessage();
+ assertFalse(message.contains("CALLER_SECRET"), message);
+ assertFalse(message.contains("SERVER_SECRET"), message);
+ assertFalse(message.contains("user:pw"), message);
+ assertTrue(message.contains("https://localhost:" + port2), message);
+ assertTrue(message.contains("http://127.0.0.1:" + port1), message);
+ }
+ }
+
+ @Test
+ public void theMessageIsBuiltFromBaseUrlsWhoeverConstructsIt() {
+ Uri source = Uri.create("https://user:pw@localhost:8443/secret/path?token=CALLER_SECRET");
+ Uri target = Uri.create("http://admin:hunter2@127.0.0.1:8080/other?token=SERVER_SECRET");
+
+ String message = new RedirectRefusedException(
+ RedirectRefusedException.Reason.SCHEME_DOWNGRADE, 307, source, target).getMessage();
+
+ assertFalse(message.contains("CALLER_SECRET"), message);
+ assertFalse(message.contains("SERVER_SECRET"), message);
+ assertFalse(message.contains("user:pw"), message);
+ assertFalse(message.contains("hunter2"), message);
+ assertFalse(message.contains("/secret/path"), message);
+ assertTrue(message.contains("https://localhost:8443"), message);
+ assertTrue(message.contains("http://127.0.0.1:8080"), message);
+ }
+
+ // ---------------------------------------------------------------- per-request override
+
+ @Test
+ public void aRequestCanRefuseWhereTheClientAllows() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig())) {
+ expectRefusal(client.prepareGet(secure("/downgrade")).setRefuseSchemeDowngradeOnRedirect(true));
+ assertFalse(targetHit.get());
+ }
+ }
+
+ @Test
+ public void aRequestCannotAllowWhereTheClientRefuses() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig().setRefuseSchemeDowngradeOnRedirect(true))) {
+ expectRefusal(client.prepareGet(secure("/downgrade")).setRefuseSchemeDowngradeOnRedirect(false));
+ assertFalse(targetHit.get(), "a request must not be able to relax the client's posture");
+ }
+ }
+
+ @Test
+ public void anOverrideSurvivesAHopThatKeepsTheBody() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig())) {
+ expectRefusal(client.preparePut(plain("/hop1")).setBody("payload")
+ .setRefuseCrossOriginBodyOnRedirect(true));
+ assertFalse(targetHit.get(), "the override must still hold on the second hop");
+ }
+ }
+
+ @Test
+ public void aRequestCannotAllowTheCrossOriginBodyHopWhereTheClientRefuses() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig().setRefuseCrossOriginBodyOnRedirect(true))) {
+ expectRefusal(client.preparePut(plain("/cross-origin")).setBody("payload")
+ .setRefuseCrossOriginBodyOnRedirect(false));
+ assertFalse(targetHit.get(), "a request must not be able to relax the client's posture");
+ }
+ }
+
+ /**
+ * A 303 rebuilds from an empty builder, the only branch where the override is copied across by hand.
+ * Both client arms are off, so a refusal can only come from the override surviving that rebuild.
+ */
+ @Test
+ public void anOverrideSurvivesTheRebuildOnABodylessHop() throws Exception {
+ try (AsyncHttpClient client = asyncHttpClient(followingConfig())) {
+ expectRefusal(client.preparePost(secure("/see-other-then-downgrade")).setBody("payload")
+ .setRefuseSchemeDowngradeOnRedirect(true));
+ assertFalse(targetHit.get());
+ }
+ }
+
+ // ---------------------------------------------------------------- fixture
+
+ private DefaultAsyncHttpClientConfig.Builder followingConfig() {
+ return config().setFollowRedirect(true).setUseInsecureTrustManager(true).setMaxRedirects(5);
+ }
+
+ private DefaultAsyncHttpClientConfig.Builder strictConfig() {
+ return followingConfig().setRefuseSchemeDowngradeOnRedirect(true).setRefuseCrossOriginBodyOnRedirect(true);
+ }
+
+ private String plain(String path) {
+ return "http://localhost:" + port1 + path;
+ }
+
+ private String secure(String path) {
+ return "https://localhost:" + port2 + path;
+ }
+
+ private static RedirectRefusedException expectRefusal(BoundRequestBuilder request) {
+ ExecutionException failure = assertThrows(ExecutionException.class,
+ () -> request.execute().get(TIMEOUT, TimeUnit.SECONDS));
+ return assertInstanceOf(RedirectRefusedException.class, failure.getCause());
+ }
+
+ private class RedirectHandler extends AbstractHandler {
+
+ @Override
+ public void handle(String target, Request baseRequest, HttpServletRequest request,
+ HttpServletResponse response) throws IOException, ServletException {
+ // Drain first: answering mid-write makes the write fail before the refusal surfaces, which makes
+ // the reported cause racy even though the refusal itself still holds.
+ String body = drain(request);
+ baseRequest.setHandled(true);
+
+ // localhost and 127.0.0.1 are different origins to the gate but the same server here, which is how
+ // a cross-origin hop is staged without a second listener.
+ String path = stripPrefix(target);
+ switch (path) {
+ case "/downgrade":
+ redirect(response, 307, "http://127.0.0.1:" + port1 + "/target?token=SERVER_SECRET");
+ return;
+ case "/upgrade":
+ redirect(response, 302, "https://localhost:" + port2 + "/target");
+ return;
+ case "/upgrade-same-port":
+ redirect(response, 307, "https://localhost:" + port1 + "/target");
+ return;
+ case "/cross-origin-301":
+ redirect(response, 301, "http://127.0.0.1:" + port1 + "/target");
+ return;
+ case "/cross-origin-302":
+ redirect(response, 302, "http://127.0.0.1:" + port1 + "/target");
+ return;
+ case "/cross-origin-308":
+ redirect(response, 308, "http://127.0.0.1:" + port1 + "/target");
+ return;
+ case "/cross-origin":
+ redirect(response, 307, "http://127.0.0.1:" + port1 + "/target");
+ return;
+ case "/same-origin":
+ redirect(response, 307, "http://localhost:" + port1 + "/target");
+ return;
+ case "/see-other":
+ redirect(response, 303, "http://127.0.0.1:" + port1 + "/target");
+ return;
+ case "/see-other-then-downgrade":
+ redirect(response, 303, "https://localhost:" + port2 + "/downgrade");
+ return;
+ case "/hop1":
+ redirect(response, 307, "http://localhost:" + port1 + "/hop2");
+ return;
+ case "/hop2":
+ redirect(response, 307, "http://127.0.0.1:" + port1 + "/target");
+ return;
+ default:
+ targetHit.set(true);
+ methodOnTarget.set(request.getMethod());
+ bodyOnTarget.set(body);
+ response.setStatus(200);
+ response.getOutputStream().write("target".getBytes(StandardCharsets.UTF_8));
+ }
+ }
+
+ private String stripPrefix(String target) {
+ int query = target.indexOf('?');
+ return query < 0 ? target : target.substring(0, query);
+ }
+
+ private void redirect(HttpServletResponse response, int status, String location) throws IOException {
+ response.setStatus(status);
+ response.setHeader("Location", location);
+ response.getOutputStream().write("moved".getBytes(StandardCharsets.UTF_8));
+ }
+
+ private String drain(HttpServletRequest request) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[1024];
+ try (InputStream in = request.getInputStream()) {
+ for (int read = in.read(buffer); read != -1; read = in.read(buffer)) {
+ out.write(buffer, 0, read);
+ }
+ }
+ return new String(out.toByteArray(), StandardCharsets.UTF_8);
+ }
+ }
+}
diff --git a/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java b/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java
index 92bd21e42..8a3206d08 100644
--- a/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java
+++ b/client/src/test/java/org/asynchttpclient/RequestBuilderTest.java
@@ -35,6 +35,7 @@
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class RequestBuilderTest {
@@ -432,4 +433,39 @@ public void testRequestToStringRedactsSensitiveHeaders() {
assertTrue(value.contains("Cookie: "));
assertTrue(value.contains("request-id"));
}
+
+ @Test
+ public void testRedirectRefusalOverridesSurviveToBuilder() {
+ Request original = new RequestBuilder("PUT")
+ .setUrl("https://example.com/upload")
+ .setRefuseSchemeDowngradeOnRedirect(true)
+ .setRefuseCrossOriginBodyOnRedirect(false)
+ .build();
+
+ // Deliberately one of each, so a swapped argument list cannot pass.
+ Request derived = original.toBuilder().build();
+ assertEquals(Boolean.TRUE, derived.getRefuseSchemeDowngradeOnRedirect());
+ assertEquals(Boolean.FALSE, derived.getRefuseCrossOriginBodyOnRedirect());
+ }
+
+ @Test
+ public void testRedirectRefusalOverridesSurviveASignatureCalculator() {
+ Request request = new RequestBuilder("PUT")
+ .setUrl("https://example.com/upload")
+ .setRefuseSchemeDowngradeOnRedirect(true)
+ .setRefuseCrossOriginBodyOnRedirect(false)
+ .setSignatureCalculator((toSign, builder) -> builder.addHeader("X-Signature", "computed"))
+ .build();
+
+ assertEquals("computed", request.getHeaders().get("X-Signature"));
+ assertEquals(Boolean.TRUE, request.getRefuseSchemeDowngradeOnRedirect());
+ assertEquals(Boolean.FALSE, request.getRefuseCrossOriginBodyOnRedirect());
+ }
+
+ @Test
+ public void testRedirectRefusalOverridesDefaultToNull() {
+ Request request = new RequestBuilder("GET").setUrl("https://example.com/").build();
+ assertNull(request.getRefuseSchemeDowngradeOnRedirect());
+ assertNull(request.getRefuseCrossOriginBodyOnRedirect());
+ }
}
diff --git a/client/src/test/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptorTest.java b/client/src/test/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptorTest.java
index 797cceea3..74caa7b21 100644
--- a/client/src/test/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptorTest.java
+++ b/client/src/test/java/org/asynchttpclient/netty/handler/intercept/Redirect30xInterceptorTest.java
@@ -15,6 +15,7 @@
*/
package org.asynchttpclient.netty.handler.intercept;
+import org.asynchttpclient.uri.Uri;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -27,6 +28,73 @@
*/
public class Redirect30xInterceptorTest {
+ private static Uri host(String scheme, String host, int port) {
+ return new Uri(scheme, null, host, port, "/", null, null);
+ }
+
+ @Test
+ public void sameOriginFoldsHostCaseButOnlyInAscii() {
+ assertTrue(Redirect30xInterceptor.sameOrigin(host("https", "example.com", 443),
+ host("https", "EXAMPLE.com", 443)));
+
+ // Not reachable from an integration test: Netty decodes header values byte per char, so a non-ASCII
+ // host can only come from the caller's own URI. String.equalsIgnoreCase would call these two equal;
+ // U+0130.example punycodes to xn--i-9bb.example, a different host.
+ assertFalse(Redirect30xInterceptor.sameOrigin(host("https", "i.example", 443),
+ host("https", "\u0130.example", 443)));
+ }
+
+ @Test
+ public void sameOriginResolvesTheDefaultPort() {
+ assertTrue(Redirect30xInterceptor.sameOrigin(host("https", "example.com", -1),
+ host("https", "example.com", 443)));
+ assertTrue(Redirect30xInterceptor.sameOrigin(host("ws", "example.com", -1),
+ host("ws", "example.com", 80)));
+ }
+
+ @Test
+ public void secureUpgradeCoversTheDefaultPortPair() {
+ assertTrue(Redirect30xInterceptor.secureUpgrade(host("http", "example.com", -1),
+ host("https", "example.com", -1)));
+ assertTrue(Redirect30xInterceptor.secureUpgrade(host("ws", "example.com", -1),
+ host("wss", "example.com", -1)));
+ assertTrue(Redirect30xInterceptor.secureUpgrade(host("http", "example.com", 8080),
+ host("https", "example.com", 8080)));
+ // Both spellings of the default pair, since getExplicitPort resolves -1 through the scheme default.
+ assertTrue(Redirect30xInterceptor.secureUpgrade(host("http", "example.com", 80),
+ host("https", "example.com", -1)));
+ assertTrue(Redirect30xInterceptor.secureUpgrade(host("http", "example.com", -1),
+ host("https", "example.com", 443)));
+ // Deliberately looser than RFC 6797 section 8.3: keeping the port lands on the endpoint the redirect
+ // was served from, so it reaches nobody new.
+ assertTrue(Redirect30xInterceptor.secureUpgrade(host("http", "example.com", 80),
+ host("https", "example.com", 80)));
+ assertTrue(Redirect30xInterceptor.secureUpgrade(host("http", "example.com", -1),
+ host("https", "example.com", 80)));
+ }
+
+ @Test
+ public void secureUpgradeRefusesAnythingButTheSameHostGoingSecure() {
+ assertFalse(Redirect30xInterceptor.secureUpgrade(host("http", "example.com", 8080),
+ host("https", "example.com", 9999)), "a port change is not an upgrade");
+ assertFalse(Redirect30xInterceptor.secureUpgrade(host("http", "example.com", -1),
+ host("https", "example.com", 8443)), "a default port moving to a non-default one is not");
+ assertFalse(Redirect30xInterceptor.secureUpgrade(host("http", "example.com", -1),
+ host("https", "other.example", -1)), "the host has to match");
+ assertFalse(Redirect30xInterceptor.secureUpgrade(host("https", "example.com", -1),
+ host("http", "example.com", -1)), "a downgrade is not an upgrade");
+ assertFalse(Redirect30xInterceptor.secureUpgrade(host("http", "example.com", -1),
+ host("wss", "example.com", -1)), "only http to https and ws to wss count");
+ }
+
+ @Test
+ public void sameOriginSeparatesSchemeAndPort() {
+ assertFalse(Redirect30xInterceptor.sameOrigin(host("https", "example.com", 443),
+ host("http", "example.com", 443)));
+ assertFalse(Redirect30xInterceptor.sameOrigin(host("https", "example.com", 443),
+ host("https", "example.com", 8443)));
+ }
+
@Test
public void acceptsTheFollowedRedirectStatuses() {
for (int statusCode : new int[]{301, 302, 303, 307, 308}) {
diff --git a/client/src/test/java/org/asynchttpclient/ws/WebSocketRedirectRefusalTest.java b/client/src/test/java/org/asynchttpclient/ws/WebSocketRedirectRefusalTest.java
new file mode 100644
index 000000000..0d5d8c9a4
--- /dev/null
+++ b/client/src/test/java/org/asynchttpclient/ws/WebSocketRedirectRefusalTest.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright (c) 2026 AsyncHttpClient Project. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.asynchttpclient.ws;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.asynchttpclient.AsyncHttpClient;
+import org.asynchttpclient.DefaultAsyncHttpClientConfig;
+import org.asynchttpclient.handler.RedirectRefusedException;
+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.eclipse.jetty.server.handler.HandlerList;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.asynchttpclient.Dsl.asyncHttpClient;
+import static org.asynchttpclient.Dsl.config;
+import static org.asynchttpclient.test.TestUtils.addHttpConnector;
+import static org.asynchttpclient.test.TestUtils.addHttpsConnector;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * {@code wss} to {@code ws} is a cleartext downgrade, and RFC 6455 section 4.1 leaves a client free not to
+ * follow it. The WebSocket path reaches the same interceptor as HTTP.
+ *
+ * Both fixture methods are re-annotated because the base class drops the annotation when it overrides
+ * {@code tearDownGlobal}, so declaring only half the pair starts a server per test and stops none.
+ */
+public class WebSocketRedirectRefusalTest extends AbstractBasicWebSocketTest {
+
+ @Override
+ @BeforeEach
+ public void setUpGlobal() throws Exception {
+ server = new Server();
+ ServerConnector plain = addHttpConnector(server);
+ ServerConnector secure = addHttpsConnector(server);
+
+ HandlerList handlers = new HandlerList();
+ handlers.addHandler(new AbstractHandler() {
+ @Override
+ public void handle(String path, Request request, HttpServletRequest servletRequest,
+ HttpServletResponse response) throws IOException {
+ if (request.getLocalPort() == port2) {
+ response.sendRedirect("ws://localhost:" + port1 + "/");
+ }
+ }
+ });
+ handlers.addHandler(configureHandler());
+ server.setHandler(handlers);
+
+ server.start();
+ port1 = plain.getLocalPort();
+ port2 = secure.getLocalPort();
+ }
+
+ @Override
+ @AfterEach
+ public void tearDownGlobal() throws Exception {
+ if (server != null) {
+ server.stop();
+ }
+ }
+
+ @Test
+ @Timeout(unit = TimeUnit.MILLISECONDS, value = 60000)
+ public void downgradeToCleartextIsRefused() throws Exception {
+ Outcome outcome = connect(config()
+ .setFollowRedirect(true)
+ .setUseInsecureTrustManager(true)
+ .setRefuseSchemeDowngradeOnRedirect(true));
+
+ assertFalse(outcome.opened.get(), "the cleartext WebSocket must never open");
+ assertInstanceOf(RedirectRefusedException.class, outcome.failure.get());
+ }
+
+ @Test
+ @Timeout(unit = TimeUnit.MILLISECONDS, value = 60000)
+ public void downgradeToCleartextIsFollowedByDefault() throws Exception {
+ Outcome outcome = connect(config()
+ .setFollowRedirect(true)
+ .setUseInsecureTrustManager(true));
+
+ assertTrue(outcome.opened.get(), "the default posture still follows the hop");
+ }
+
+ private Outcome connect(DefaultAsyncHttpClientConfig.Builder builder) throws Exception {
+ Outcome outcome = new Outcome();
+ try (AsyncHttpClient client = asyncHttpClient(builder)) {
+ client.prepareGet("wss://localhost:" + port2 + "/")
+ .execute(new WebSocketUpgradeHandler.Builder().addWebSocketListener(new WebSocketListener() {
+
+ @Override
+ public void onOpen(WebSocket websocket) {
+ outcome.opened.set(true);
+ outcome.done.countDown();
+ }
+
+ @Override
+ public void onClose(WebSocket websocket, int code, String reason) {
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ outcome.failure.set(t);
+ outcome.done.countDown();
+ }
+ }).build());
+
+ assertTrue(outcome.done.await(30, TimeUnit.SECONDS), "neither onOpen nor onError fired");
+ }
+ return outcome;
+ }
+
+ private static class Outcome {
+ final CountDownLatch done = new CountDownLatch(1);
+ final AtomicBoolean opened = new AtomicBoolean();
+ final AtomicReference failure = new AtomicReference<>();
+ }
+}