From fd07b83dd4d1ca94a9e8b782117620de15307223 Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Wed, 26 Aug 2026 01:15:50 +0200 Subject: [PATCH 1/7] Preserve every redirect body type Redirect30xInterceptor copied only six request-body representations when a redirect retained the body. Composite byte arrays, ByteBufs, InputStreams, and Files therefore became empty requests on 307, 308, and strict 302 redirects. Copy the representation selected for the original request, following NettyRequestFactory's precedence. Resettable streams can then replay, while non-resettable streams fail promptly through the existing replay guard. Cover all four omissions byte-for-byte, pin coexistence precedence, retain the caller-owned ByteBuf, and keep form and multipart replay behavior. OpenAI Codex on behalf of Matthias Kurz. Co-Authored-By: OpenAI Codex --- .../intercept/Redirect30xInterceptor.java | 44 +++-- .../org/asynchttpclient/RedirectBodyTest.java | 158 +++++++++++++++++- 2 files changed, 182 insertions(+), 20 deletions(-) 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 80cac6d4f9..be0accef55 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 @@ -157,20 +157,7 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture } if (keepBody) { - requestBuilder.setCharset(request.getCharset()); - if (isNonEmpty(request.getFormParams())) { - requestBuilder.setFormParams(request.getFormParams()); - } else if (request.getStringData() != null) { - requestBuilder.setBody(request.getStringData()); - } else if (request.getByteData() != null) { - requestBuilder.setBody(request.getByteData()); - } else if (request.getByteBufferData() != null) { - requestBuilder.setBody(request.getByteBufferData()); - } else if (request.getBodyGenerator() != null) { - requestBuilder.setBody(request.getBodyGenerator()); - } else if (isNonEmpty(request.getBodyParts())) { - requestBuilder.setBodyParts(request.getBodyParts()); - } + copyBody(requestBuilder, request); } requestBuilder.setHeaders(propagatedHeaders(request, realm, keepBody, stripAuth)); @@ -229,6 +216,35 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture return false; } + private static void copyBody(RequestBuilder requestBuilder, Request request) { + requestBuilder.setCharset(request.getCharset()); + + // Keep this precedence aligned with NettyRequestFactory.body. A Request can retain a File or + // BodyGenerator alongside another representation, so the redirect must copy the representation + // that the original request actually sent. + if (request.getByteData() != null) { + requestBuilder.setBody(request.getByteData()); + } else if (request.getCompositeByteData() != null) { + requestBuilder.setBody(request.getCompositeByteData()); + } else if (request.getStringData() != null) { + requestBuilder.setBody(request.getStringData()); + } else if (request.getByteBufferData() != null) { + requestBuilder.setBody(request.getByteBufferData()); + } else if (request.getByteBufData() != null) { + requestBuilder.setBody(request.getByteBufData()); + } else if (request.getStreamData() != null) { + requestBuilder.setBody(request.getStreamData()); + } else if (isNonEmpty(request.getFormParams())) { + requestBuilder.setFormParams(request.getFormParams()); + } else if (isNonEmpty(request.getBodyParts())) { + requestBuilder.setBodyParts(request.getBodyParts()); + } else if (request.getFile() != null) { + requestBuilder.setBody(request.getFile()); + } else if (request.getBodyGenerator() != null) { + requestBuilder.setBody(request.getBodyGenerator()); + } + } + private static HttpHeaders propagatedHeaders(Request request, Realm realm, boolean keepBody, boolean stripAuthorization) { HttpHeaders headers = request.getHeaders() .remove(HOST) diff --git a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java index 461c7a06a0..d67e134464 100644 --- a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java +++ b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java @@ -16,25 +16,43 @@ package org.asynchttpclient; import io.github.artsok.RepeatedIfExceptionsTest; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.io.IOUtils; +import org.asynchttpclient.request.body.multipart.StringPart; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.handler.AbstractHandler; import org.junit.jupiter.api.BeforeEach; +import java.io.ByteArrayInputStream; +import java.io.FilterInputStream; import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; +import static java.nio.charset.StandardCharsets.UTF_8; import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE; import static io.netty.handler.codec.http.HttpHeaderNames.LOCATION; import static org.asynchttpclient.Dsl.asyncHttpClient; import static org.asynchttpclient.Dsl.config; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +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; public class RedirectBodyTest extends AbstractBasicTest { + private static final byte[] REDIRECT_BODY = "redirect body".getBytes(UTF_8); + private static final String CONTENT_TYPE_VALUE = "application/octet-stream"; + private static volatile boolean redirectAlreadyPerformed; private static volatile String receivedContentType; @@ -50,6 +68,7 @@ public AbstractHandler configureHandler() throws Exception { @Override public void handle(String pathInContext, Request request, HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws IOException { + byte[] body = IOUtils.toByteArray(request.getInputStream()); String redirectHeader = httpRequest.getHeader("X-REDIRECT"); if (redirectHeader != null && !redirectAlreadyPerformed) { redirectAlreadyPerformed = true; @@ -60,12 +79,9 @@ public void handle(String pathInContext, Request request, HttpServletRequest htt } else { receivedContentType = request.getContentType(); httpResponse.setStatus(200); - int len = request.getContentLength(); - httpResponse.setContentLength(len); - if (len > 0) { - byte[] buffer = new byte[len]; - IOUtils.read(request.getInputStream(), buffer); - httpResponse.getOutputStream().write(buffer); + httpResponse.setContentLength(body.length); + if (body.length > 0) { + httpResponse.getOutputStream().write(body); } } httpResponse.getOutputStream().flush(); @@ -121,4 +137,134 @@ public void regular307KeepsBody() throws Exception { assertEquals(receivedContentType, contentType); } } + + @RepeatedIfExceptionsTest(repeats = 5) + public void compositeByteArray307KeepsBody() throws Exception { + try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + byte[] first = "redirect ".getBytes(UTF_8); + byte[] second = "body".getBytes(UTF_8); + + Response response = execute307(c.preparePost(getTargetUrl()).setBody(Arrays.asList(first, second))); + + assertRedirectBody(response); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void byteBuf307KeepsBody() throws Exception { + ByteBuf body = Unpooled.wrappedBuffer(REDIRECT_BODY); + try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + Response response = execute307(c.preparePost(getTargetUrl()).setBody(body)); + + assertRedirectBody(response); + assertEquals(1, body.refCnt(), "the caller must retain ownership of its ByteBuf"); + } finally { + body.release(); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void resettableInputStream307KeepsBody() throws Exception { + try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + Response response = execute307(c.preparePost(getTargetUrl()).setBody(new ByteArrayInputStream(REDIRECT_BODY))); + + assertRedirectBody(response); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void nonResettableInputStream307FailsPromptly() throws Exception { + InputStream body = new FilterInputStream(new ByteArrayInputStream(REDIRECT_BODY)) { + @Override + public boolean markSupported() { + return false; + } + + @Override + public synchronized void reset() throws IOException { + throw new IOException("reset not supported"); + } + }; + try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + ExecutionException thrown = assertThrows(ExecutionException.class, + () -> execute307(c.preparePost(getTargetUrl()).setBody(body))); + + assertInstanceOf(IOException.class, thrown.getCause()); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void file307KeepsBody() throws Exception { + Path body = Files.createTempFile("ahc-redirect-body-", ".bin"); + try { + Files.write(body, REDIRECT_BODY); + try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + Response response = execute307(c.preparePost(getTargetUrl()).setBody(body.toFile())); + + assertRedirectBody(response); + } + } finally { + Files.deleteIfExists(body); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void coexistingFileAndByteArray308UsesByteArray() throws Exception { + Path file = Files.createTempFile("ahc-redirect-precedence-", ".bin"); + try { + Files.write(file, "wrong file body".getBytes(UTF_8)); + try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + Response response = c.preparePost(getTargetUrl()) + .setBody(file.toFile()) + .setBody(REDIRECT_BODY) + .setHeader(CONTENT_TYPE, CONTENT_TYPE_VALUE) + .setHeader("X-REDIRECT", "308") + .execute() + .get(TIMEOUT, TimeUnit.SECONDS); + + assertRedirectBody(response); + } + } finally { + Files.deleteIfExists(file); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void formParams307KeepBody() throws Exception { + try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + Response response = c.preparePost(getTargetUrl()) + .addFormParam("field", "value") + .setHeader("X-REDIRECT", "307") + .execute() + .get(TIMEOUT, TimeUnit.SECONDS); + + assertEquals("field=value", response.getResponseBody()); + } + } + + @RepeatedIfExceptionsTest(repeats = 5) + public void multipart307KeepsBody() throws Exception { + try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + Response response = c.preparePost(getTargetUrl()) + .addBodyPart(new StringPart("field", "multipart value")) + .setHeader("X-REDIRECT", "307") + .execute() + .get(TIMEOUT, TimeUnit.SECONDS); + + assertTrue(response.getResponseBody().contains("multipart value")); + } + } + + private static Response execute307(BoundRequestBuilder requestBuilder) throws Exception { + return requestBuilder + .setHeader(CONTENT_TYPE, CONTENT_TYPE_VALUE) + .setHeader("X-REDIRECT", "307") + .execute() + .get(TIMEOUT, TimeUnit.SECONDS); + } + + private static void assertRedirectBody(Response response) { + assertArrayEquals(REDIRECT_BODY, response.getResponseBodyAsBytes()); + assertEquals(CONTENT_TYPE_VALUE, receivedContentType); + } } From 7fb41149c987ce0450127ac79de5b99e830cfd0d Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Thu, 27 Aug 2026 22:37:50 +0200 Subject: [PATCH 2/7] Preserve redirect request settings Build keep-body redirects from the original request so every supported body representation and per-request option follows the redirect without duplicating NettyRequestFactory's selection logic. Clear target-specific routing and credential state when the origin changes, and copy headers before removing redirect-only values. Cover read timeout, range offset, and Cookie object handling. OpenAI Codex on behalf of Matthias Kurz. Co-Authored-By: OpenAI Codex --- .../intercept/Redirect30xInterceptor.java | 66 +++++++------------ .../org/asynchttpclient/RedirectBodyTest.java | 35 ++++++++++ .../RedirectCredentialSecurityTest.java | 20 ++++++ 3 files changed, 78 insertions(+), 43 deletions(-) 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 be0accef55..ba81e64953 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 @@ -56,7 +56,6 @@ import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.SEE_OTHER_303; import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.TEMPORARY_REDIRECT_307; import static org.asynchttpclient.util.HttpUtils.followRedirect; -import static org.asynchttpclient.util.MiscUtils.isNonEmpty; import static org.asynchttpclient.util.ThrowableUtil.unknownStackTrace; public class Redirect30xInterceptor { @@ -132,13 +131,27 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture LOGGER.debug("Stripping credentials on redirect to {}", newUri); } - final RequestBuilder requestBuilder = new RequestBuilder(switchToGet ? GET : originalMethod) - .setChannelPoolPartitioning(request.getChannelPoolPartitioning()) + final RequestBuilder requestBuilder; + if (keepBody) { + requestBuilder = request.toBuilder(); + if (!sameBase) { + // An explicitly resolved address and virtual host belong to the previous target. + requestBuilder.setAddress(null); + requestBuilder.setVirtualHost(null); + } + } else { + requestBuilder = new RequestBuilder(switchToGet ? GET : originalMethod) + .setChannelPoolPartitioning(request.getChannelPoolPartitioning()) + .setLocalAddress(request.getLocalAddress()) + .setNameResolver(request.getNameResolver()) + .setProxyServer(request.getProxyServer()) + .setRangeOffset(request.getRangeOffset()); + } + + requestBuilder.setMethod(switchToGet ? GET : originalMethod) .setFollowRedirect(true) - .setLocalAddress(request.getLocalAddress()) - .setNameResolver(request.getNameResolver()) - .setProxyServer(request.getProxyServer()) .setRealm(stripAuth ? null : request.getRealm()) + .setHeaders(propagatedHeaders(request, realm, keepBody, stripAuth)) .setRequestTimeout(request.getRequestTimeout()) .setReadTimeout(request.getReadTimeout()); @@ -154,14 +167,10 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture if (stripAuth) { future.setRealm(null); future.setProxyRealm(null); + // Request.toBuilder copies Cookie objects separately from the Cookie header. + requestBuilder.resetCookies(); } - if (keepBody) { - copyBody(requestBuilder, request); - } - - requestBuilder.setHeaders(propagatedHeaders(request, realm, keepBody, stripAuth)); - // in case of a redirect from HTTP to HTTPS, future // attributes might change final boolean initialConnectionKeepAlive = future.isKeepAlive(); @@ -179,7 +188,7 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture } } - if (sameBase) { + if (sameBase && !keepBody) { // we can only assume the virtual host is still valid if the baseUrl is the same requestBuilder.setVirtualHost(request.getVirtualHost()); } @@ -216,37 +225,8 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture return false; } - private static void copyBody(RequestBuilder requestBuilder, Request request) { - requestBuilder.setCharset(request.getCharset()); - - // Keep this precedence aligned with NettyRequestFactory.body. A Request can retain a File or - // BodyGenerator alongside another representation, so the redirect must copy the representation - // that the original request actually sent. - if (request.getByteData() != null) { - requestBuilder.setBody(request.getByteData()); - } else if (request.getCompositeByteData() != null) { - requestBuilder.setBody(request.getCompositeByteData()); - } else if (request.getStringData() != null) { - requestBuilder.setBody(request.getStringData()); - } else if (request.getByteBufferData() != null) { - requestBuilder.setBody(request.getByteBufferData()); - } else if (request.getByteBufData() != null) { - requestBuilder.setBody(request.getByteBufData()); - } else if (request.getStreamData() != null) { - requestBuilder.setBody(request.getStreamData()); - } else if (isNonEmpty(request.getFormParams())) { - requestBuilder.setFormParams(request.getFormParams()); - } else if (isNonEmpty(request.getBodyParts())) { - requestBuilder.setBodyParts(request.getBodyParts()); - } else if (request.getFile() != null) { - requestBuilder.setBody(request.getFile()); - } else if (request.getBodyGenerator() != null) { - requestBuilder.setBody(request.getBodyGenerator()); - } - } - private static HttpHeaders propagatedHeaders(Request request, Realm realm, boolean keepBody, boolean stripAuthorization) { - HttpHeaders headers = request.getHeaders() + HttpHeaders headers = request.getHeaders().copy() .remove(HOST) .remove(CONTENT_LENGTH); diff --git a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java index d67e134464..0d4c53fdb6 100644 --- a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java +++ b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java @@ -21,6 +21,8 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.apache.commons.io.IOUtils; +import org.asynchttpclient.filter.FilterContext; +import org.asynchttpclient.filter.ResponseFilter; import org.asynchttpclient.request.body.multipart.StringPart; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.handler.AbstractHandler; @@ -32,7 +34,10 @@ import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -138,6 +143,36 @@ public void regular307KeepsBody() throws Exception { } } + @RepeatedIfExceptionsTest(repeats = 5) + public void redirectPreservesPerRequestSettings() throws Exception { + Duration readTimeout = Duration.ofSeconds(7); + long rangeOffset = 41L; + List observedReadTimeouts = new CopyOnWriteArrayList<>(); + List observedRangeOffsets = new CopyOnWriteArrayList<>(); + ResponseFilter observer = new ResponseFilter() { + @Override + public FilterContext filter(FilterContext ctx) { + observedReadTimeouts.add(ctx.getRequest().getReadTimeout()); + observedRangeOffsets.add(ctx.getRequest().getRangeOffset()); + return ctx; + } + }; + + try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true).addResponseFilter(observer))) { + Response response = c.preparePost(getTargetUrl()) + .setReadTimeout(readTimeout) + .setRangeOffset(rangeOffset) + .setBody(REDIRECT_BODY) + .setHeader("X-REDIRECT", "307") + .execute() + .get(TIMEOUT, TimeUnit.SECONDS); + + assertArrayEquals(REDIRECT_BODY, response.getResponseBodyAsBytes()); + assertEquals(List.of(readTimeout, readTimeout), observedReadTimeouts); + assertEquals(List.of(rangeOffset, rangeOffset), observedRangeOffsets); + } + } + @RepeatedIfExceptionsTest(repeats = 5) public void compositeByteArray307KeepsBody() throws Exception { try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { diff --git a/client/src/test/java/org/asynchttpclient/RedirectCredentialSecurityTest.java b/client/src/test/java/org/asynchttpclient/RedirectCredentialSecurityTest.java index daa9676d47..74e30c7fc5 100644 --- a/client/src/test/java/org/asynchttpclient/RedirectCredentialSecurityTest.java +++ b/client/src/test/java/org/asynchttpclient/RedirectCredentialSecurityTest.java @@ -535,6 +535,26 @@ void crossDomainRedirectStripsCookieHeader() throws Exception { } } + @Test + void crossDomainRedirectStripsCookieObject() throws Exception { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() + .setFollowRedirect(true) + .build(); + try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) { + lastCookieHeaderOnA.set(null); + lastCookieHeaderOnB.set(null); + + client.prepareGet("http://127.0.0.1:" + portA + "/redirect-to-b") + .addCookie(new DefaultCookie("session", "abc123")) + .execute() + .get(5, TimeUnit.SECONDS); + + assertEquals("session=abc123", lastCookieHeaderOnA.get()); + assertNull(lastCookieHeaderOnB.get(), + "Cookie objects must not be copied to a cross-domain redirect target"); + } + } + /** * Same-origin redirect (same host and port) should preserve the Cookie header. */ From 67145c7a4ed81736e776cec832bc6fb9e6d83437 Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Thu, 27 Aug 2026 22:38:45 +0200 Subject: [PATCH 3/7] Strengthen stream replay test Close the test InputStream and assert the precise replay failure instead of accepting any IOException. This pins the intended fail-fast behavior for a consumed non-resettable stream. OpenAI Codex on behalf of Matthias Kurz. Co-Authored-By: OpenAI Codex --- .../test/java/org/asynchttpclient/RedirectBodyTest.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java index 0d4c53fdb6..c676f1b397 100644 --- a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java +++ b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java @@ -209,7 +209,7 @@ public void resettableInputStream307KeepsBody() throws Exception { @RepeatedIfExceptionsTest(repeats = 5) public void nonResettableInputStream307FailsPromptly() throws Exception { - InputStream body = new FilterInputStream(new ByteArrayInputStream(REDIRECT_BODY)) { + try (InputStream body = new FilterInputStream(new ByteArrayInputStream(REDIRECT_BODY)) { @Override public boolean markSupported() { return false; @@ -220,11 +220,13 @@ public synchronized void reset() throws IOException { throw new IOException("reset not supported"); } }; - try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { ExecutionException thrown = assertThrows(ExecutionException.class, () -> execute307(c.preparePost(getTargetUrl()).setBody(body))); - assertInstanceOf(IOException.class, thrown.getCause()); + IOException cause = assertInstanceOf(IOException.class, thrown.getCause()); + assertEquals("HTTP/1 request body InputStream already consumed and cannot be reset for a retry", + cause.getMessage()); } } From 1ec9f87790c93f09533732ecd9cad9be4a0eabc6 Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Thu, 27 Aug 2026 22:39:37 +0200 Subject: [PATCH 4/7] Cover file stream replay failure Exercise a real file-backed InputStream across a keep-body redirect. The first send closes the stream, so the replay must fail promptly with the documented IOException instead of sending an empty body. OpenAI Codex on behalf of Matthias Kurz. Co-Authored-By: OpenAI Codex --- .../org/asynchttpclient/RedirectBodyTest.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java index c676f1b397..d82079be9c 100644 --- a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java +++ b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java @@ -230,6 +230,25 @@ public synchronized void reset() throws IOException { } } + @RepeatedIfExceptionsTest(repeats = 5) + public void fileInputStream307FailsPromptly() throws Exception { + Path bodyFile = Files.createTempFile("ahc-redirect-stream-", ".bin"); + try { + Files.write(bodyFile, REDIRECT_BODY); + try (InputStream body = Files.newInputStream(bodyFile); + AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + ExecutionException thrown = assertThrows(ExecutionException.class, + () -> execute307(c.preparePost(getTargetUrl()).setBody(body))); + + IOException cause = assertInstanceOf(IOException.class, thrown.getCause()); + assertEquals("HTTP/1 request body InputStream already consumed and cannot be reset for a retry", + cause.getMessage()); + } + } finally { + Files.deleteIfExists(bodyFile); + } + } + @RepeatedIfExceptionsTest(repeats = 5) public void file307KeepsBody() throws Exception { Path body = Files.createTempFile("ahc-redirect-body-", ".bin"); From 6a7842dabf40be6a14a36d69be701153af4951c8 Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Thu, 27 Aug 2026 22:40:36 +0200 Subject: [PATCH 5/7] Preserve redirected stream length Keep an explicit Content-Length when replaying a raw InputStream. Unlike other body representations, the stream has no intrinsic size from which the redirect request can recompute the header. Verify both request legs receive the same length and body bytes. OpenAI Codex on behalf of Matthias Kurz. Co-Authored-By: OpenAI Codex --- .../intercept/Redirect30xInterceptor.java | 10 +++++++--- .../org/asynchttpclient/RedirectBodyTest.java | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) 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 ba81e64953..6f344823a9 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 @@ -226,9 +226,13 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture } private static HttpHeaders propagatedHeaders(Request request, Realm realm, boolean keepBody, boolean stripAuthorization) { - HttpHeaders headers = request.getHeaders().copy() - .remove(HOST) - .remove(CONTENT_LENGTH); + HttpHeaders headers = request.getHeaders().copy().remove(HOST); + + // A raw InputStream has no intrinsic length from which NettyRequestFactory can rebuild this header. + // Preserve a caller-supplied value when the stream itself is replayed. + if (!keepBody || request.getStreamData() == null) { + headers.remove(CONTENT_LENGTH); + } if (!keepBody) { headers.remove(CONTENT_TYPE); diff --git a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java index d82079be9c..090cf6357d 100644 --- a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java +++ b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java @@ -42,6 +42,7 @@ import java.util.concurrent.TimeUnit; import static java.nio.charset.StandardCharsets.UTF_8; +import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH; import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE; import static io.netty.handler.codec.http.HttpHeaderNames.LOCATION; import static org.asynchttpclient.Dsl.asyncHttpClient; @@ -58,11 +59,13 @@ public class RedirectBodyTest extends AbstractBasicTest { private static final byte[] REDIRECT_BODY = "redirect body".getBytes(UTF_8); private static final String CONTENT_TYPE_VALUE = "application/octet-stream"; + private static final List receivedContentLengths = new CopyOnWriteArrayList<>(); private static volatile boolean redirectAlreadyPerformed; private static volatile String receivedContentType; @BeforeEach public void setUp() { + receivedContentLengths.clear(); redirectAlreadyPerformed = false; receivedContentType = null; } @@ -74,6 +77,7 @@ public AbstractHandler configureHandler() throws Exception { public void handle(String pathInContext, Request request, HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws IOException { byte[] body = IOUtils.toByteArray(request.getInputStream()); + receivedContentLengths.add(String.valueOf(httpRequest.getHeader(CONTENT_LENGTH.toString()))); String redirectHeader = httpRequest.getHeader("X-REDIRECT"); if (redirectHeader != null && !redirectAlreadyPerformed) { redirectAlreadyPerformed = true; @@ -207,6 +211,20 @@ public void resettableInputStream307KeepsBody() throws Exception { } } + @RepeatedIfExceptionsTest(repeats = 5) + public void inputStream307PreservesExplicitContentLength() throws Exception { + try (InputStream body = new ByteArrayInputStream(REDIRECT_BODY); + AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + Response response = execute307(c.preparePost(getTargetUrl()) + .setHeader(CONTENT_LENGTH, REDIRECT_BODY.length) + .setBody(body)); + + assertRedirectBody(response); + String expectedLength = Integer.toString(REDIRECT_BODY.length); + assertEquals(List.of(expectedLength, expectedLength), receivedContentLengths); + } + } + @RepeatedIfExceptionsTest(repeats = 5) public void nonResettableInputStream307FailsPromptly() throws Exception { try (InputStream body = new FilterInputStream(new ByteArrayInputStream(REDIRECT_BODY)) { From 858927ffe24a7c270c9b4391118363552c33e2c6 Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Thu, 27 Aug 2026 22:41:57 +0200 Subject: [PATCH 6/7] Fail fast on streamed multipart replay Reject keep-body redirects containing InputStreamPart before constructing the second request. Multipart stream parts are closed after the first send and have no replay guard, so attempting to reuse them can hang or send incomplete content. Cover the failure with a real file-backed multipart stream. OpenAI Codex on behalf of Matthias Kurz. Co-Authored-By: OpenAI Codex --- .../intercept/Redirect30xInterceptor.java | 13 ++++++++++ .../org/asynchttpclient/RedirectBodyTest.java | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+) 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 6f344823a9..83dc9c2a2d 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 @@ -33,10 +33,13 @@ import org.asynchttpclient.netty.channel.PrincipalScopedPartitionKey; import org.asynchttpclient.netty.request.NettyRequestSender; import io.netty.handler.codec.http2.Http2StreamChannel; +import org.asynchttpclient.request.body.multipart.InputStreamPart; +import org.asynchttpclient.request.body.multipart.Part; import org.asynchttpclient.uri.Uri; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.IOException; import java.util.HashSet; import java.util.Set; @@ -133,6 +136,7 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture final RequestBuilder requestBuilder; if (keepBody) { + ensureBodyReplayable(request); requestBuilder = request.toBuilder(); if (!sameBase) { // An explicitly resolved address and virtual host belong to the previous target. @@ -225,6 +229,15 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture return false; } + private static void ensureBodyReplayable(Request request) throws IOException { + for (Part part : request.getBodyParts()) { + if (part instanceof InputStreamPart) { + throw new IOException("Multipart InputStream body part '" + part.getName() + + "' cannot be replayed after redirect"); + } + } + } + private static HttpHeaders propagatedHeaders(Request request, Realm realm, boolean keepBody, boolean stripAuthorization) { HttpHeaders headers = request.getHeaders().copy().remove(HOST); diff --git a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java index 090cf6357d..ddea47296c 100644 --- a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java +++ b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java @@ -23,6 +23,7 @@ import org.apache.commons.io.IOUtils; import org.asynchttpclient.filter.FilterContext; import org.asynchttpclient.filter.ResponseFilter; +import org.asynchttpclient.request.body.multipart.InputStreamPart; import org.asynchttpclient.request.body.multipart.StringPart; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.handler.AbstractHandler; @@ -329,6 +330,30 @@ public void multipart307KeepsBody() throws Exception { } } + @RepeatedIfExceptionsTest(repeats = 5) + public void inputStreamMultipart307FailsPromptly() throws Exception { + Path bodyFile = Files.createTempFile("ahc-redirect-multipart-", ".bin"); + try { + Files.write(bodyFile, REDIRECT_BODY); + try (InputStream body = Files.newInputStream(bodyFile); + AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + ExecutionException thrown = assertThrows(ExecutionException.class, + () -> c.preparePost(getTargetUrl()) + .addBodyPart(new InputStreamPart("file", body, bodyFile.getFileName().toString(), + REDIRECT_BODY.length, CONTENT_TYPE_VALUE)) + .setHeader("X-REDIRECT", "307") + .execute() + .get(TIMEOUT, TimeUnit.SECONDS)); + + IOException cause = assertInstanceOf(IOException.class, thrown.getCause()); + assertEquals("Multipart InputStream body part 'file' cannot be replayed after redirect", + cause.getMessage()); + } + } finally { + Files.deleteIfExists(bodyFile); + } + } + private static Response execute307(BoundRequestBuilder requestBuilder) throws Exception { return requestBuilder .setHeader(CONTENT_TYPE, CONTENT_TYPE_VALUE) From 6268d4d8fe725c73e3d983dca0748210fcbee310 Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Thu, 27 Aug 2026 22:44:09 +0200 Subject: [PATCH 7/7] Fail safely when redirect file vanished Validate the file body actually selected by the original request before dispatching a keep-body redirect. Report a checked IOException before the redirect path can remove a pooled channel and fail with an unchecked constructor exception. Preserve body-selection precedence when a sticky File coexists with a higher-priority representation. OpenAI Codex on behalf of Matthias Kurz. Co-Authored-By: OpenAI Codex --- .../intercept/Redirect30xInterceptor.java | 29 +++++++++++++++++++ .../org/asynchttpclient/RedirectBodyTest.java | 25 ++++++++++++++++ 2 files changed, 54 insertions(+) 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 83dc9c2a2d..8efc7cb688 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 @@ -33,12 +33,14 @@ import org.asynchttpclient.netty.channel.PrincipalScopedPartitionKey; import org.asynchttpclient.netty.request.NettyRequestSender; import io.netty.handler.codec.http2.Http2StreamChannel; +import org.asynchttpclient.request.body.generator.FileBodyGenerator; import org.asynchttpclient.request.body.multipart.InputStreamPart; import org.asynchttpclient.request.body.multipart.Part; import org.asynchttpclient.uri.Uri; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; import java.io.IOException; import java.util.HashSet; import java.util.Set; @@ -236,6 +238,33 @@ private static void ensureBodyReplayable(Request request) throws IOException { + "' cannot be replayed after redirect"); } } + + File file = selectedBodyFile(request); + if (file != null && !file.isFile()) { + throw new IOException("Redirect request body file " + file.getAbsolutePath() + + " is not a file or does not exist"); + } + } + + private static File selectedBodyFile(Request request) { + // Keep this precedence aligned with NettyRequestFactory.body. A File can remain set alongside a + // higher-priority representation, so only validate it when the original request actually sent it. + if (request.getByteData() != null + || request.getCompositeByteData() != null + || request.getStringData() != null + || request.getByteBufferData() != null + || request.getByteBufData() != null + || request.getStreamData() != null + || !request.getFormParams().isEmpty() + || !request.getBodyParts().isEmpty()) { + return null; + } + if (request.getFile() != null) { + return request.getFile(); + } + return request.getBodyGenerator() instanceof FileBodyGenerator + ? ((FileBodyGenerator) request.getBodyGenerator()).getFile() + : null; } private static HttpHeaders propagatedHeaders(Request request, Realm realm, boolean keepBody, boolean stripAuthorization) { diff --git a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java index ddea47296c..03141ffc43 100644 --- a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java +++ b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java @@ -63,12 +63,14 @@ public class RedirectBodyTest extends AbstractBasicTest { private static final List receivedContentLengths = new CopyOnWriteArrayList<>(); private static volatile boolean redirectAlreadyPerformed; private static volatile String receivedContentType; + private static volatile Path fileToDeleteBeforeRedirect; @BeforeEach public void setUp() { receivedContentLengths.clear(); redirectAlreadyPerformed = false; receivedContentType = null; + fileToDeleteBeforeRedirect = null; } @Override @@ -82,6 +84,9 @@ public void handle(String pathInContext, Request request, HttpServletRequest htt String redirectHeader = httpRequest.getHeader("X-REDIRECT"); if (redirectHeader != null && !redirectAlreadyPerformed) { redirectAlreadyPerformed = true; + if (fileToDeleteBeforeRedirect != null) { + Files.deleteIfExists(fileToDeleteBeforeRedirect); + } httpResponse.setStatus(Integer.valueOf(redirectHeader)); httpResponse.setContentLength(0); httpResponse.setHeader(LOCATION.toString(), getTargetUrl()); @@ -283,11 +288,31 @@ public void file307KeepsBody() throws Exception { } } + @RepeatedIfExceptionsTest(repeats = 5) + public void vanishedFile307FailsPromptly() throws Exception { + Path body = Files.createTempFile("ahc-redirect-vanished-", ".bin"); + try { + Files.write(body, REDIRECT_BODY); + fileToDeleteBeforeRedirect = body; + try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + ExecutionException thrown = assertThrows(ExecutionException.class, + () -> execute307(c.preparePost(getTargetUrl()).setBody(body.toFile()))); + + IOException cause = assertInstanceOf(IOException.class, thrown.getCause()); + assertEquals("Redirect request body file " + body.toAbsolutePath() + + " is not a file or does not exist", cause.getMessage()); + } + } finally { + Files.deleteIfExists(body); + } + } + @RepeatedIfExceptionsTest(repeats = 5) public void coexistingFileAndByteArray308UsesByteArray() throws Exception { Path file = Files.createTempFile("ahc-redirect-precedence-", ".bin"); try { Files.write(file, "wrong file body".getBytes(UTF_8)); + fileToDeleteBeforeRedirect = file; try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { Response response = c.preparePost(getTargetUrl()) .setBody(file.toFile())