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<>(); + } +}