From 015ed8919bafb17bdb29e9368161d7c60d7079af Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Wed, 26 Aug 2026 00:39:44 +0200 Subject: [PATCH 1/5] Preserve non-POST redirect methods RFC 9110 scopes the historical 301 and 302 POST-to-GET rewrite to POST. AHC applied it to other methods, silently dropping content from PUT, PATCH, DELETE, and extension requests. Retain the established POST behavior while repeating those non-POST requests with their bodies. Cross-origin redirects still strip credentials even though request content is replayed. OpenAI Codex on behalf of Matthias Kurz. Co-Authored-By: OpenAI Codex --- .../intercept/Redirect30xInterceptor.java | 21 ++++---- .../org/asynchttpclient/RedirectBodyTest.java | 41 +++++++++++--- .../RedirectCredentialSecurityTest.java | 53 +++++++++++++++++++ 3 files changed, 98 insertions(+), 17 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 1450f2361..9bdc9ffd8 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,7 @@ import static org.asynchttpclient.util.HttpConstants.Methods.GET; import static org.asynchttpclient.util.HttpConstants.Methods.HEAD; import static org.asynchttpclient.util.HttpConstants.Methods.OPTIONS; -import static org.asynchttpclient.util.HttpConstants.Methods.QUERY; +import static org.asynchttpclient.util.HttpConstants.Methods.POST; import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.FOUND_302; import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.MOVED_PERMANENTLY_301; import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.PERMANENT_REDIRECT_308; @@ -117,18 +117,17 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture future.setScramContext(null); String originalMethod = request.getMethod(); - boolean isQuery = QUERY.equals(originalMethod); - boolean methodAlreadyPreserved = originalMethod.equals(GET) || + boolean isPost = originalMethod.equals(POST); + boolean bodylessMethod = originalMethod.equals(GET) || originalMethod.equals(OPTIONS) || originalMethod.equals(HEAD); boolean strict302 = statusCode == FOUND_302 && config.isStrict302Handling(); - // RFC 10008 section 2.5 excludes QUERY from the legacy POST-to-GET behavior. - boolean queryRedirect = isQuery && - (statusCode == MOVED_PERMANENTLY_301 || statusCode == FOUND_302); - boolean legacyRedirectToGet = statusCode == MOVED_PERMANENTLY_301 || - (statusCode == FOUND_302 && !strict302); - boolean switchToGet = !methodAlreadyPreserved && - (statusCode == SEE_OTHER_303 || (!isQuery && legacyRedirectToGet)); - boolean keepBody = queryRedirect || + // RFC 9110 limits the historical 301/302 POST-to-GET rewrite to POST. + boolean switchToGet = !bodylessMethod && + (statusCode == SEE_OTHER_303 || + (isPost && (statusCode == MOVED_PERMANENTLY_301 || + (statusCode == FOUND_302 && !strict302)))); + boolean keepBody = (!bodylessMethod && !isPost && + (statusCode == MOVED_PERMANENTLY_301 || statusCode == FOUND_302)) || statusCode == TEMPORARY_REDIRECT_307 || statusCode == PERMANENT_REDIRECT_308 || strict302; diff --git a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java index 6ab7fd3e2..c9e98316b 100644 --- a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java +++ b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java @@ -281,16 +281,45 @@ public synchronized void reset() throws IOException { } } - @ParameterizedTest(name = "{0} on {1} keeps the existing GET rewrite") + @RepeatedIfExceptionsTest(repeats = 5) + public void put301WithNonRepeatableBodyGeneratorFailsPromptly() throws Exception { + try (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"); + } + }; + AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + ExecutionException thrown = assertThrows(ExecutionException.class, + () -> c.preparePut(getTargetUrl()) + .setBody(new InputStreamBodyGenerator(body)) + .setHeader("X-REDIRECT", "301") + .execute() + .get(TIMEOUT, TimeUnit.SECONDS)); + + IOException cause = assertInstanceOf(IOException.class, thrown.getCause()); + assertEquals("HTTP/1 request body InputStream already consumed and cannot be reset for a retry", + cause.getMessage()); + } + } + + @ParameterizedTest(name = "{0} on {1} keeps its method and body") @CsvSource({ "PUT, 301", "PUT, 302", "PATCH, 301", "PATCH, 302", "DELETE, 301", - "DELETE, 302" + "DELETE, 302", + "CUSTOM, 301", + "CUSTOM, 302" }) - public void putPatchAndDelete301And302KeepExistingBehavior(String method, int statusCode) throws Exception { + public void nonPost301And302KeepMethodAndBody(String method, int statusCode) throws Exception { try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { String body = "hello there"; String contentType = "text/plain; charset=UTF-8"; @@ -301,9 +330,9 @@ public void putPatchAndDelete301And302KeepExistingBehavior(String method, int st .setHeader("X-REDIRECT", Integer.toString(statusCode)) .execute() .get(TIMEOUT, TimeUnit.SECONDS); - assertEquals("", response.getResponseBody()); - assertEquals(GET, receivedMethod); - assertNull(receivedContentType); + assertEquals(body, response.getResponseBody()); + assertEquals(method, receivedMethod); + assertEquals(contentType, receivedContentType); } } diff --git a/client/src/test/java/org/asynchttpclient/RedirectCredentialSecurityTest.java b/client/src/test/java/org/asynchttpclient/RedirectCredentialSecurityTest.java index faada9961..00ed7d2e4 100644 --- a/client/src/test/java/org/asynchttpclient/RedirectCredentialSecurityTest.java +++ b/client/src/test/java/org/asynchttpclient/RedirectCredentialSecurityTest.java @@ -71,6 +71,11 @@ public class RedirectCredentialSecurityTest { private static final AtomicReference query301ContentTypeOnTarget = new AtomicReference<>(); private static final AtomicReference query301MethodOnTarget = new AtomicReference<>(); private static final AtomicReference query301BodyOnTarget = new AtomicReference<>(); + private static final AtomicReference put301AuthOnTarget = new AtomicReference<>(); + private static final AtomicReference put301CookieOnTarget = new AtomicReference<>(); + private static final AtomicReference put301ContentTypeOnTarget = new AtomicReference<>(); + private static final AtomicReference put301MethodOnTarget = new AtomicReference<>(); + private static final AtomicReference put301BodyOnTarget = new AtomicReference<>(); private static final AtomicReference lastCookieHeaderOnA = new AtomicReference<>(); private static final AtomicReference lastCookieHeaderOnB = new AtomicReference<>(); private static final AtomicReference cookieAtChainStep2 = new AtomicReference<>(); @@ -213,6 +218,24 @@ public static void startServers() throws Exception { exchange.close(); }); + serverA.createContext("/redirect-put-301-to-b", exchange -> { + exchange.getRequestBody().readAllBytes(); + exchange.getResponseHeaders().add("Location", "http://127.0.0.1:" + portB + "/target-put-301"); + exchange.sendResponseHeaders(301, -1); + exchange.close(); + }); + + serverB.createContext("/target-put-301", exchange -> { + put301AuthOnTarget.set(exchange.getRequestHeaders().getFirst("Authorization")); + put301CookieOnTarget.set(exchange.getRequestHeaders().getFirst("Cookie")); + put301ContentTypeOnTarget.set(exchange.getRequestHeaders().getFirst("Content-Type")); + put301MethodOnTarget.set(exchange.getRequestMethod()); + put301BodyOnTarget.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().close(); + exchange.close(); + }); + // Endpoint reused by the HTTPS-to-HTTP downgrade test (target on server B over plain HTTP) serverB.createContext("/target-after-downgrade", exchange -> { authAfterHttpsDowngrade.set(exchange.getRequestHeaders().getFirst("Authorization")); @@ -565,6 +588,36 @@ void query301CrossOriginStripsCredentialsAndPreservesRequest() throws Exception } } + @Test + void put301CrossOriginStripsCredentialsAndPreservesRequest() throws Exception { + DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() + .setFollowRedirect(true) + .build(); + try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) { + put301AuthOnTarget.set(null); + put301CookieOnTarget.set(null); + put301ContentTypeOnTarget.set(null); + put301MethodOnTarget.set(null); + put301BodyOnTarget.set(null); + + client.preparePut("http://127.0.0.1:" + portA + "/redirect-put-301-to-b") + .setHeader("Authorization", "Bearer secret-token") + .setHeader("Cookie", "session=secret-session") + .setHeader("Content-Type", "application/octet-stream") + .setBody("sensitive-content") + .execute() + .get(5, TimeUnit.SECONDS); + + assertNull(put301AuthOnTarget.get(), + "Authorization must be stripped on a cross-origin PUT redirect"); + assertNull(put301CookieOnTarget.get(), + "Cookie must be stripped on a cross-origin PUT redirect"); + assertEquals("PUT", put301MethodOnTarget.get()); + assertEquals("application/octet-stream", put301ContentTypeOnTarget.get()); + assertEquals("sensitive-content", put301BodyOnTarget.get()); + } + } + /** * Cross-domain redirect (different port) must strip a user-supplied Cookie header. * Regression test for GHSA-fmxf-pm6p-7xgm. From ba32a323d9072950e2aa25fab4040e92b7f61f8c Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Sun, 6 Sep 2026 01:19:27 +0200 Subject: [PATCH 2/5] Preserve bodies for unchanged redirect methods Keep request content whenever a redirect preserves its method, except for the explicit body-dropping semantics of 303. This covers GET, HEAD, OPTIONS, and caller-added redirect statuses while retaining the historical POST rewrite for 301 and non-strict 302. Co-Authored-By: OpenAI Codex --- .../intercept/Redirect30xInterceptor.java | 16 ++++---- .../org/asynchttpclient/RedirectBodyTest.java | 40 +++++++++++++++++-- 2 files changed, 44 insertions(+), 12 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 9bdc9ffd8..1ba55bf4b 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 @@ -118,18 +118,16 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture String originalMethod = request.getMethod(); boolean isPost = originalMethod.equals(POST); - boolean bodylessMethod = originalMethod.equals(GET) || + boolean methodAlreadyPreserved = originalMethod.equals(GET) || originalMethod.equals(OPTIONS) || originalMethod.equals(HEAD); boolean strict302 = statusCode == FOUND_302 && config.isStrict302Handling(); // RFC 9110 limits the historical 301/302 POST-to-GET rewrite to POST. - boolean switchToGet = !bodylessMethod && - (statusCode == SEE_OTHER_303 || - (isPost && (statusCode == MOVED_PERMANENTLY_301 || - (statusCode == FOUND_302 && !strict302)))); - boolean keepBody = (!bodylessMethod && !isPost && - (statusCode == MOVED_PERMANENTLY_301 || statusCode == FOUND_302)) || - statusCode == TEMPORARY_REDIRECT_307 || statusCode == PERMANENT_REDIRECT_308 || - strict302; + // This also preserves QUERY as required by RFC 10008 section 2.5. + boolean legacyPostToGet = isPost && (statusCode == MOVED_PERMANENTLY_301 || + (statusCode == FOUND_302 && !strict302)); + boolean switchToGet = !methodAlreadyPreserved && + (statusCode == SEE_OTHER_303 || legacyPostToGet); + boolean keepBody = statusCode != SEE_OTHER_303 && !switchToGet; HttpHeaders responseHeaders = response.headers(); String location = responseHeaders.get(LOCATION); diff --git a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java index c9e98316b..4a625b9a3 100644 --- a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java +++ b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java @@ -52,6 +52,7 @@ import static io.netty.handler.codec.http.HttpHeaderNames.LOCATION; import static org.asynchttpclient.Dsl.asyncHttpClient; import static org.asynchttpclient.Dsl.config; +import static org.asynchttpclient.netty.handler.intercept.Redirect30xInterceptor.REDIRECT_STATUSES; import static org.asynchttpclient.util.HttpConstants.Methods.GET; import static org.asynchttpclient.util.HttpConstants.Methods.POST; import static org.asynchttpclient.util.HttpConstants.Methods.QUERY; @@ -69,6 +70,7 @@ public class RedirectBodyTest extends AbstractBasicTest { private static final List receivedContentLengths = new CopyOnWriteArrayList<>(); private static volatile boolean redirectAlreadyPerformed; + private static volatile byte[] receivedBody; private static volatile String receivedContentType; private static volatile String receivedMethod; private static volatile Path fileToDeleteBeforeRedirect; @@ -77,6 +79,7 @@ public class RedirectBodyTest extends AbstractBasicTest { public void setUp() { receivedContentLengths.clear(); redirectAlreadyPerformed = false; + receivedBody = null; receivedContentType = null; receivedMethod = null; fileToDeleteBeforeRedirect = null; @@ -101,6 +104,7 @@ public void handle(String pathInContext, Request request, HttpServletRequest htt httpResponse.setHeader(LOCATION.toString(), getTargetUrl()); } else { + receivedBody = body; receivedContentType = request.getContentType(); receivedMethod = request.getMethod(); httpResponse.setStatus(200); @@ -317,22 +321,52 @@ public synchronized void reset() throws IOException { "DELETE, 301", "DELETE, 302", "CUSTOM, 301", - "CUSTOM, 302" + "CUSTOM, 302", + "GET, 301", + "GET, 302", + "HEAD, 301", + "HEAD, 302", + "OPTIONS, 301", + "OPTIONS, 302" }) public void nonPost301And302KeepMethodAndBody(String method, int statusCode) throws Exception { try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { String body = "hello there"; String contentType = "text/plain; charset=UTF-8"; - Response response = c.prepare(method, getTargetUrl()) + c.prepare(method, getTargetUrl()) .setHeader(CONTENT_TYPE, contentType) .setBody(body) .setHeader("X-REDIRECT", Integer.toString(statusCode)) .execute() .get(TIMEOUT, TimeUnit.SECONDS); - assertEquals(body, response.getResponseBody()); + assertArrayEquals(body.getBytes(UTF_8), receivedBody); + assertEquals(method, receivedMethod); + assertEquals(contentType, receivedContentType); + } + } + + @ParameterizedTest(name = "{0} on caller-added 300 keeps its method and body") + @CsvSource({"POST", "PUT"}) + public void callerAddedRedirectStatusKeepsMethodAndBody(String method) throws Exception { + boolean added = REDIRECT_STATUSES.add(300); + try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + String body = "hello there"; + String contentType = "text/plain; charset=UTF-8"; + + c.prepare(method, getTargetUrl()) + .setHeader(CONTENT_TYPE, contentType) + .setBody(body) + .setHeader("X-REDIRECT", "300") + .execute() + .get(TIMEOUT, TimeUnit.SECONDS); + assertArrayEquals(body.getBytes(UTF_8), receivedBody); assertEquals(method, receivedMethod); assertEquals(contentType, receivedContentType); + } finally { + if (added) { + REDIRECT_STATUSES.remove(300); + } } } From 872800aa11526d1e4e234d32addffcb3ee14bc3f Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Sun, 6 Sep 2026 01:22:02 +0200 Subject: [PATCH 3/5] Validate only the selected redirect body Request builders can retain lower-priority body representations. Reuse the outbound body precedence for redirect checks so stale multipart streams do not reject replayable byte-array redirects. Co-Authored-By: OpenAI Codex --- .../intercept/Redirect30xInterceptor.java | 111 +++++++++++------- .../org/asynchttpclient/RedirectBodyTest.java | 13 ++ 2 files changed, 83 insertions(+), 41 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 1ba55bf4b..0071d23e2 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 @@ -240,66 +240,95 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture } 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"); + BodyRepresentation bodyRepresentation = selectedBodyRepresentation(request); + if (bodyRepresentation == BodyRepresentation.BODY_PARTS) { + for (Part part : request.getBodyParts()) { + if (part instanceof InputStreamPart) { + throw new IOException("Multipart InputStream body part '" + part.getName() + + "' cannot be replayed after redirect"); + } } } - File file = selectedBodyFile(request); + File file = null; + if (bodyRepresentation == BodyRepresentation.FILE) { + file = request.getFile(); + } else if (bodyRepresentation == BodyRepresentation.FILE_BODY_GENERATOR) { + file = ((FileBodyGenerator) request.getBodyGenerator()).getFile(); + } 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 (hasBodyBeforeFile(request)) { - return null; + private static BodyRepresentation selectedBodyRepresentation(Request request) { + // Keep this precedence aligned with NettyRequestFactory.body. Some setters leave lower-priority + // representations in place, so redirect validation must inspect only the body that was sent. + if (request.getByteData() != null) { + return BodyRepresentation.BYTE_DATA; + } + if (request.getCompositeByteData() != null) { + return BodyRepresentation.COMPOSITE_BYTE_DATA; + } + if (request.getStringData() != null) { + return BodyRepresentation.STRING_DATA; + } + if (request.getByteBufferData() != null) { + return BodyRepresentation.BYTE_BUFFER_DATA; + } + if (request.getByteBufData() != null) { + return BodyRepresentation.BYTE_BUF_DATA; + } + if (request.getStreamData() != null) { + return BodyRepresentation.STREAM_DATA; + } + if (!request.getFormParams().isEmpty()) { + return BodyRepresentation.FORM_PARAMS; + } + if (!request.getBodyParts().isEmpty()) { + return BodyRepresentation.BODY_PARTS; } if (request.getFile() != null) { - return request.getFile(); + return BodyRepresentation.FILE; } - return request.getBodyGenerator() instanceof FileBodyGenerator - ? ((FileBodyGenerator) request.getBodyGenerator()).getFile() - : null; - } - - private static boolean hasBodyBeforeFile(Request request) { - return hasBodyBeforeStream(request) - || request.getStreamData() != null - || !request.getFormParams().isEmpty() - || !request.getBodyParts().isEmpty(); - } - - private static boolean hasBodyBeforeStream(Request request) { - return request.getByteData() != null - || request.getCompositeByteData() != null - || request.getStringData() != null - || request.getByteBufferData() != null - || request.getByteBufData() != null; + if (request.getBodyGenerator() instanceof FileBodyGenerator) { + return BodyRepresentation.FILE_BODY_GENERATOR; + } + if (request.getBodyGenerator() instanceof InputStreamBodyGenerator) { + return BodyRepresentation.INPUT_STREAM_BODY_GENERATOR; + } + return request.getBodyGenerator() == null + ? BodyRepresentation.NONE + : BodyRepresentation.BODY_GENERATOR; } private static boolean selectedBodyHasUnknownLength(Request request) { - if (hasBodyBeforeStream(request)) { - return false; - } - if (request.getStreamData() != null) { + BodyRepresentation bodyRepresentation = selectedBodyRepresentation(request); + if (bodyRepresentation == BodyRepresentation.STREAM_DATA + || bodyRepresentation == BodyRepresentation.BODY_GENERATOR) { return true; } - if (!request.getFormParams().isEmpty() - || !request.getBodyParts().isEmpty() - || request.getFile() != null) { - return false; - } - if (request.getBodyGenerator() instanceof InputStreamBodyGenerator) { + if (bodyRepresentation == BodyRepresentation.INPUT_STREAM_BODY_GENERATOR) { return ((InputStreamBodyGenerator) request.getBodyGenerator()).getContentLength() < 0; } - return request.getBodyGenerator() != null - && !(request.getBodyGenerator() instanceof FileBodyGenerator); + return false; + } + + private enum BodyRepresentation { + BYTE_DATA, + COMPOSITE_BYTE_DATA, + STRING_DATA, + BYTE_BUFFER_DATA, + BYTE_BUF_DATA, + STREAM_DATA, + FORM_PARAMS, + BODY_PARTS, + FILE, + FILE_BODY_GENERATOR, + INPUT_STREAM_BODY_GENERATOR, + BODY_GENERATOR, + NONE } 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 4a625b9a3..9f748bf7e 100644 --- a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java +++ b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java @@ -590,6 +590,19 @@ public void coexistingFileAndByteArray308UsesByteArray() throws Exception { } } + @RepeatedIfExceptionsTest(repeats = 5) + public void coexistingMultipartStreamAndByteArray307UsesByteArray() throws Exception { + try (InputStream unusedPart = new ByteArrayInputStream("unused part".getBytes(UTF_8)); + AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + Response response = execute307(c.preparePost(getTargetUrl()) + .setBody(REDIRECT_BODY) + .setBodyParts(List.of(new InputStreamPart("file", unusedPart, "unused.bin", + "unused part".length(), CONTENT_TYPE_VALUE)))); + + assertRedirectBody(response); + } + } + @RepeatedIfExceptionsTest(repeats = 5) public void formParams307KeepBody() throws Exception { try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { From 78930e8dcc8223790d27bf14b7376e636d736d85 Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Sun, 6 Sep 2026 01:24:41 +0200 Subject: [PATCH 4/5] Preflight non-replayable redirect bodies Reject selected raw streams and InputStream body generators that declare no mark/reset support before opening the redirect target. The write-time reset remains the final check for streams that advertise support but cannot actually reset after their first send. Co-Authored-By: OpenAI Codex --- .../handler/intercept/Redirect30xInterceptor.java | 12 ++++++++++++ .../java/org/asynchttpclient/RedirectBodyTest.java | 14 ++++++-------- 2 files changed, 18 insertions(+), 8 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 0071d23e2..b6819efc6 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 @@ -43,6 +43,7 @@ import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.util.HashSet; import java.util.Set; @@ -260,6 +261,17 @@ private static void ensureBodyReplayable(Request request) throws IOException { throw new IOException("Redirect request body file " + file.getAbsolutePath() + " is not a file or does not exist"); } + + InputStream inputStream = null; + if (bodyRepresentation == BodyRepresentation.STREAM_DATA) { + inputStream = request.getStreamData(); + } else if (bodyRepresentation == BodyRepresentation.INPUT_STREAM_BODY_GENERATOR) { + inputStream = ((InputStreamBodyGenerator) request.getBodyGenerator()).getInputStream(); + } + if (inputStream != null && !inputStream.markSupported()) { + throw new IOException("Redirect request body InputStream does not support mark/reset" + + " and cannot be replayed"); + } } private static BodyRepresentation selectedBodyRepresentation(Request request) { diff --git a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java index 9f748bf7e..d1aa0bf0f 100644 --- a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java +++ b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java @@ -67,6 +67,8 @@ 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 String NON_REPLAYABLE_STREAM_MESSAGE = + "Redirect request body InputStream does not support mark/reset and cannot be replayed"; private static final List receivedContentLengths = new CopyOnWriteArrayList<>(); private static volatile boolean redirectAlreadyPerformed; @@ -280,8 +282,7 @@ public synchronized void reset() throws IOException { .get(TIMEOUT, TimeUnit.SECONDS)); IOException cause = assertInstanceOf(IOException.class, thrown.getCause()); - assertEquals("HTTP/1 request body InputStream already consumed and cannot be reset for a retry", - cause.getMessage()); + assertEquals(NON_REPLAYABLE_STREAM_MESSAGE, cause.getMessage()); } } @@ -307,8 +308,7 @@ public synchronized void reset() throws IOException { .get(TIMEOUT, TimeUnit.SECONDS)); IOException cause = assertInstanceOf(IOException.class, thrown.getCause()); - assertEquals("HTTP/1 request body InputStream already consumed and cannot be reset for a retry", - cause.getMessage()); + assertEquals(NON_REPLAYABLE_STREAM_MESSAGE, cause.getMessage()); } } @@ -510,8 +510,7 @@ public synchronized void reset() throws IOException { () -> 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()); + assertEquals(NON_REPLAYABLE_STREAM_MESSAGE, cause.getMessage()); } } @@ -526,8 +525,7 @@ public void fileInputStream307FailsPromptly() throws Exception { () -> 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()); + assertEquals(NON_REPLAYABLE_STREAM_MESSAGE, cause.getMessage()); } } finally { Files.deleteIfExists(bodyFile); From 271010164b9e8c505c6b8985b0f85204dbad893c Mon Sep 17 00:00:00 2001 From: Matthias Kurz Date: Sun, 6 Sep 2026 01:25:53 +0200 Subject: [PATCH 5/5] Strengthen cross-origin redirect coverage Prove that a cross-origin PUT redirect receives credentials only on its original leg while preserving the method, content type, and body on the target leg. Also cover body preservation when the redirect changes the hostname without changing the server. Co-Authored-By: OpenAI Codex --- .../org/asynchttpclient/RedirectBodyTest.java | 19 +++++++++++++++++++ .../RedirectCredentialSecurityTest.java | 12 +++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java index d1aa0bf0f..1fb4b7d1b 100644 --- a/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java +++ b/client/src/test/java/org/asynchttpclient/RedirectBodyTest.java @@ -346,6 +346,25 @@ public void nonPost301And302KeepMethodAndBody(String method, int statusCode) thr } } + @RepeatedIfExceptionsTest(repeats = 5) + public void put301AcrossDifferentHostsKeepsMethodAndBody() throws Exception { + try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) { + String body = "hello there"; + String contentType = "text/plain; charset=UTF-8"; + String originalUrl = getTargetUrl().replace("localhost", "127.0.0.1"); + + c.preparePut(originalUrl) + .setHeader(CONTENT_TYPE, contentType) + .setBody(body) + .setHeader("X-REDIRECT", "301") + .execute() + .get(TIMEOUT, TimeUnit.SECONDS); + assertArrayEquals(body.getBytes(UTF_8), receivedBody); + assertEquals("PUT", receivedMethod); + assertEquals(contentType, receivedContentType); + } + } + @ParameterizedTest(name = "{0} on caller-added 300 keeps its method and body") @CsvSource({"POST", "PUT"}) public void callerAddedRedirectStatusKeepsMethodAndBody(String method) throws Exception { diff --git a/client/src/test/java/org/asynchttpclient/RedirectCredentialSecurityTest.java b/client/src/test/java/org/asynchttpclient/RedirectCredentialSecurityTest.java index 00ed7d2e4..82d2570b4 100644 --- a/client/src/test/java/org/asynchttpclient/RedirectCredentialSecurityTest.java +++ b/client/src/test/java/org/asynchttpclient/RedirectCredentialSecurityTest.java @@ -71,6 +71,8 @@ public class RedirectCredentialSecurityTest { private static final AtomicReference query301ContentTypeOnTarget = new AtomicReference<>(); private static final AtomicReference query301MethodOnTarget = new AtomicReference<>(); private static final AtomicReference query301BodyOnTarget = new AtomicReference<>(); + private static final AtomicReference put301AuthOnOriginal = new AtomicReference<>(); + private static final AtomicReference put301CookieOnOriginal = new AtomicReference<>(); private static final AtomicReference put301AuthOnTarget = new AtomicReference<>(); private static final AtomicReference put301CookieOnTarget = new AtomicReference<>(); private static final AtomicReference put301ContentTypeOnTarget = new AtomicReference<>(); @@ -219,6 +221,8 @@ public static void startServers() throws Exception { }); serverA.createContext("/redirect-put-301-to-b", exchange -> { + put301AuthOnOriginal.set(exchange.getRequestHeaders().getFirst("Authorization")); + put301CookieOnOriginal.set(exchange.getRequestHeaders().getFirst("Cookie")); exchange.getRequestBody().readAllBytes(); exchange.getResponseHeaders().add("Location", "http://127.0.0.1:" + portB + "/target-put-301"); exchange.sendResponseHeaders(301, -1); @@ -589,11 +593,13 @@ void query301CrossOriginStripsCredentialsAndPreservesRequest() throws Exception } @Test - void put301CrossOriginStripsCredentialsAndPreservesRequest() throws Exception { + void put301CrossOriginReplaysBodyAndStripsCredentials() throws Exception { DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder() .setFollowRedirect(true) .build(); try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) { + put301AuthOnOriginal.set(null); + put301CookieOnOriginal.set(null); put301AuthOnTarget.set(null); put301CookieOnTarget.set(null); put301ContentTypeOnTarget.set(null); @@ -608,6 +614,10 @@ void put301CrossOriginStripsCredentialsAndPreservesRequest() throws Exception { .execute() .get(5, TimeUnit.SECONDS); + assertEquals("Bearer secret-token", put301AuthOnOriginal.get(), + "Authorization must be present on the original PUT request"); + assertEquals("session=secret-session", put301CookieOnOriginal.get(), + "Cookie must be present on the original PUT request"); assertNull(put301AuthOnTarget.get(), "Authorization must be stripped on a cross-origin PUT redirect"); assertNull(put301CookieOnTarget.get(),