Skip to content

fix(netty): preserve all redirect body types - #2316

Open
mkurz wants to merge 7 commits into
AsyncHttpClient:mainfrom
mkurz:fix/redirect-body-replay
Open

fix(netty): preserve all redirect body types#2316
mkurz wants to merge 7 commits into
AsyncHttpClient:mainfrom
mkurz:fix/redirect-body-replay

Conversation

@mkurz

@mkurz mkurz commented Aug 25, 2026

Copy link
Copy Markdown

Summary

  • Build keep-body redirects from the original request, preserving every supported body representation and per-request setting.
  • Clear target-specific routing and credential state when a redirect crosses an origin, including separately stored Cookie objects.
  • Preserve an explicit Content-Length when replaying a raw InputStream.
  • Replay resettable streams and fail promptly for consumed raw streams, streamed multipart parts, and vanished files that cannot be replayed safely.
  • Pin body bytes, headers, body-selection precedence, caller-owned ByteBuf references, and the new failure modes with focused tests.

Problem

Redirect30xInterceptor rebuilds a request when it follows a strict 302, 307, or 308 redirect. Its keep-body copy chain handled form parameters, strings, byte arrays, ByteBuffer, body generators, and multipart bodies, but omitted four real request send paths:

  • List<byte[]> / composite byte arrays
  • Netty ByteBuf
  • InputStream
  • File

The redirected request therefore kept its method and Content-Type but sent zero bytes. The File case is especially risky for uploads because the target can accept an apparently valid empty PUT or POST. Reconstructing the request field by field also omitted unrelated per-request state such as the read timeout and range offset, and maintaining a second body-selection chain alongside NettyRequestFactory made future drift likely.

This is a pre-existing omission. AHC issue #1643 previously fixed the same class of bug for multipart bodies. The copy chain was carried through pull request #1843 without a policy discussion. Focused searches found no existing issue or pull request covering these four representations.

Change

Build a keep-body redirect with request.toBuilder() and then replace only redirect-specific state. This preserves all current and future body representations and per-request options without duplicating NettyRequestFactory.body. Headers are copied before redirect-only values are removed, so the original request is not mutated.

On a cross-origin redirect, the copied request drops the previous resolved address, virtual host, realm, authorization headers, and Cookie objects before the cookie store adds cookies that legitimately match the new URI. The body itself follows the existing redirect policy unchanged.

Composite byte arrays, caller-owned ByteBufs, and files are repeatable. A resettable InputStream, such as ByteArrayInputStream, also replays, and a caller-supplied Content-Length is retained because a raw stream has no intrinsic size from which to recompute it. A consumed stream that cannot be reset reaches the existing fail-fast guard added in #2312 and completes the future with IOException; that is preferable to silently succeeding with an empty body.

An InputStreamPart is closed by the first multipart send and has no equivalent replay guard, so a keep-body redirect now fails promptly instead of risking a hang or incomplete multipart request. A selected File or FileBodyGenerator is also checked before dispatching the redirect; if it disappeared after the first send, the future fails with IOException before a target pooled channel can be removed and an unchecked constructor exception can escape. The validation follows NettyRequestFactory precedence so a sticky File field is ignored when a higher-priority body representation was actually sent.

The change does not alter which methods or status codes keep a body, nor does it introduce a new cross-origin policy. It makes the existing strict-302, 307, and 308 behavior complete for every supported request-body representation.

Compatibility

There is no public API change. Requests that previously sent an empty body on a keep-body redirect now resend their configured body.

Behavior changes:

  • A non-resettable InputStream on a keep-body redirect previously completed successfully after sending an empty redirected request. It now completes the request future exceptionally with IOException. This includes FileInputStream, which is closed after the first send and cannot be reset for replay.
  • A multipart InputStreamPart now fails promptly with IOException when a keep-body redirect requires replay. Reusing its already-consumed and closed stream could previously hang or send incomplete multipart content.
  • A selected file that disappears between the first request and redirect now fails with IOException before redirect dispatch rather than allowing an unchecked IllegalArgumentException to escape while constructing the next request.

Callers that accidentally relied on an empty or incomplete redirected request will observe an exception, but the failure is explicit instead of silently losing configured content. There is no public API change.

AI disclosure

OpenAI Codex on behalf of Matthias Kurz. The commit includes Co-Authored-By: OpenAI Codex <codex@openai.com> per AGENTS.md.

Test plan

  • On untouched upstream/main, the focused suite reproduced five failures: four body types arrived as zero bytes and a non-resettable stream incorrectly completed successfully.
  • ./mvnw -pl client -Dtest=RedirectBodyTest,RedirectCredentialSecurityTest test on JDK 11: 38 tests passed, including Netty leak detection.
  • ./mvnw clean verify on JDK 11 (BUILD SUCCESS).

Generated with OpenAI Codex.

@mkurz

mkurz commented Aug 26, 2026

Copy link
Copy Markdown
Author

We use ahc in https://github.com/playframework/play-ws and I am in the process of upgrading to v3 - and found some thing worth adressing.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we build this from request.toBuilder() and override method, uri, headers and realm after? RequestBuilderBase already copies all the body fields, plus charset, rangeOffset and readTimeout.

The builder above copies setRequestTimeout but not setReadTimeout, so a per-request read timeout silently falls back to the config default after the first redirect. toBuilder() would fix that too, and this wouldn't drift out of sync with NettyRequestFactory.body again.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - the keep-body path now builds from request.toBuilder() and only overrides method, URI, headers, realm and follow-redirect. readTimeout and rangeOffset are now carried on both paths (the non-keep-body branch sets them explicitly), and copyBody is gone.

Two things fell out of copying everything. toBuilder() also copies the resolved address, virtual host and Cookie objects, so those are cleared on a cross-origin redirect - the Cookie objects are separate from the Cookie header that propagatedHeaders strips. And propagatedHeaders was calling .remove() directly on request.getHeaders(), which is the live instance; that was harmless while the original request was discarded, but would have corrupted the prototype once we started building from it. It now copies first.

One place the precedence check does come back: selectedBodyFile() re-encodes the NettyRequestFactory.body order, because validating the File (your comment below) requires knowing whether the file is the representation that will actually be sent - a File can stay set behind a higher-priority body. I kept it as a pre-check rather than catching NettyFileBody's IllegalArgumentException, since you asked for the failure to surface before dispatch. Happy to swap it for the catch if you'd rather not have the ordering repeated anywhere.

requestBuilder.setBody(request.getByteBufferData());
} else if (request.getByteBufData() != null) {
requestBuilder.setBody(request.getByteBufData());
} else if (request.getStreamData() != null) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WriteProgressListener closes the stream after the first leg, so this only replays if close() is a no-op. ByteArrayInputStream is fine, Files.newInputStream(...) and anything buffered will fail the reset() in NettyInputStreamBody.

We used to send an empty body on the redirect leg, so this turns a silent bug into an IOException for the common case. That's the right trade I think, but it needs a release note, and a test with a real FileInputStream and not just ByteArrayInputStream.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added fileInputStream307FailsPromptly, which uses Files.newInputStream(...) rather than ByteArrayInputStream and asserts the exact guard message. The Compatibility section of the PR body now calls out FileInputStream by name as a behavior change: it previously completed successfully after sending an empty redirected request, and now fails with IOException.

On the release note - I don't see a CHANGELOG in the repo or a convention in AGENTS.md, so I've treated the PR body as the artifact. Is that what you had in mind, or is there somewhere else this should be recorded?

} else if (request.getByteBufData() != null) {
requestBuilder.setBody(request.getByteBufData());
} else if (request.getStreamData() != null) {
requestBuilder.setBody(request.getStreamData());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

propagatedHeaders removes CONTENT_LENGTH and the rebuilt NettyInputStreamBody doesn't have a length, so a stream that went out with Content-Length on the first leg goes out chunked on the second. Some targets won't accept a chunked request body. Can we carry the original length over?

@mkurz mkurz Aug 27, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 6a7842d. propagatedHeaders now preserves a caller-supplied Content-Length specifically when replaying a raw InputStream on a keep-body redirect; other body representations still have the header removed and recomputed normally. inputStream307PreservesExplicitContentLength asserts that both legs carry the original Content-Length and that the redirected request delivers the original bytes.

requestBuilder.setBody(request.getStreamData());
} else if (isNonEmpty(request.getFormParams())) {
requestBuilder.setFormParams(request.getFormParams());
} else if (isNonEmpty(request.getBodyParts())) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InputStreamMultipartPart closes its stream once it has written it, and we don't have a consumed check here like the one NettyInputStreamBody got in #2312.

So for an InputStreamPart the replayed request still advertises the declared Content-Length, but transferContentTo sees -1 straight away and writes nothing. The server then waits for a body that never arrives. Should we detect the parts we can't replay and fail fast the same way?

@mkurz mkurz Aug 27, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added - ensureBodyReplayable now rejects any InputStreamPart before the request is rebuilt, so it fails ahead of any channel work rather than advertising a Content-Length it can't satisfy. Test uses an InputStreamPart with a declared length, since StringPart is replayable and passes either way.

One thing worth your call: this IOException (and the vanished-file one below) propagates out of exitAfterHandlingRedirect into HttpHandler.handleRead, which routes IOException through applyIoExceptionFiltersAndReplayRequest. With no IOExceptionFilter configured, that is a straight abort. If a configured IOExceptionFilter requests replay and the future remains replayable, these failures can be retried up to maxRequestRetry. Avoiding that would require either a non-IOException exception or an explicit exclusion before applying the filters; an IOException subclass alone would not change the routing. Do you want either of those, or is the default-config behavior enough?

requestBuilder.setFormParams(request.getFormParams());
} else if (isNonEmpty(request.getBodyParts())) {
requestBuilder.setBodyParts(request.getBodyParts());
} else if (request.getFile() != null) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Today the File is dropped on redirect, so a 307 to another origin sends nothing. After this we upload the whole file to whatever host Location points at, and a hostile target can bounce us maxRedirects times to get several copies.

Same origin is fine. Cross origin I'd like to be a deliberate decision, and same for getStreamData() above.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that this is security-sensitive, so let me be precise about what does and does not change.

The exposure is not new in kind: several body representations, including byte arrays, strings, ByteBuffer, form parameters, multipart bodies, and replayable body generators, already replay across origins on 307, 308, and strict 302, once per hop up to maxRedirects.

This PR makes composite byte arrays, ByteBuf, InputStream, and File bodies follow that existing policy too. The most consequential change is for File and InputStream: a redirect leg that previously received an empty body can now receive the complete upload, which may be materially larger. That is worth stating plainly.

What I would rather not do is carve out particular body representations. Dropping only those preserves the method while sending an empty payload, which is exactly the silent data-corruption bug this PR fixes, and would make cross-origin behavior depend on which body setter the caller used.

The redirect boundary still strips Authorization, Proxy-Authorization, the Cookie header, Cookie objects, the realm, and target-specific routing state. The cookie store can only contribute cookies valid for the redirected URI.

If AHC should let callers refuse cross-origin body replay, I think that belongs as a representation-independent policy applied to every keep-body redirect. Because retaining the method while stripping its body is unsafe, I would prefer such a policy to decline or fail the redirect rather than silently remove the payload. I'll open a follow-up issue for that design and keep this PR focused on faithfully replaying bodies under the current policy. Say the word if you would prefer that policy to be included here.

} else if (isNonEmpty(request.getBodyParts())) {
requestBuilder.setBodyParts(request.getBodyParts());
} else if (request.getFile() != null) {
requestBuilder.setBody(request.getFile());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NettyFileBody throws IllegalArgumentException if the file is gone by now, and we're on the event loop here. Nothing in newNettyRequestAndResponseFuture catches it, so it ends up in exceptionCaught after drainChannelAndOffer already gave the old channel back to the pool, and then we close a connection another request might be using. Check the file here and abort the future with an IOException instead?

@mkurz mkurz Aug 27, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 6268d4d. ensureBodyReplayable determines whether a File or FileBodyGenerator was the representation actually selected, checks it before rebuilding or dispatching the redirect, and throws IOException if it has disappeared. vanishedFile307FailsPromptly deletes the file between the first response and redirect and asserts the exact error. The coexistence test also confirms that a stale, lower-priority File is not rejected when another body representation was actually sent. The IOExceptionFilter caveat is covered in the InputStreamPart thread above.

ExecutionException thrown = assertThrows(ExecutionException.class,
() -> execute307(c.preparePost(getTargetUrl()).setBody(body)));

assertInstanceOf(IOException.class, thrown.getCause());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any channel-level failure is an IOException too, so this still passes if the replay guard in NettyInputStreamBody goes away. Assert on the message instead. The FilterInputStream is also never closed.

@mkurz mkurz Aug 27, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 67145c7. The FilterInputStream is now managed by try-with-resources, and the test asserts the exact replay-guard message, HTTP/1 request body InputStream already consumed and cannot be reset for a retry, in addition to checking the exception type. An unrelated channel-level IOException therefore no longer satisfies the test.

public void multipart307KeepsBody() throws Exception {
try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) {
Response response = c.preparePost(getTargetUrl())
.addBodyPart(new StringPart("field", "multipart value"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StringPart is replayable so this passes either way. The one that breaks is InputStreamPart with a declared length, please add that one.

@mkurz mkurz Aug 27, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 858927f. inputStreamMultipart307FailsPromptly uses a real file-backed InputStreamPart with a declared length and asserts the exact fail-fast IOException. The StringPart test remains to pin the replayable multipart path.

mkurz and others added 7 commits August 28, 2026 00:04
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 <codex@openai.com>
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 <codex@openai.com>
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 <codex@openai.com>
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 <codex@openai.com>
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 <codex@openai.com>
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 <codex@openai.com>
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 <codex@openai.com>
@mkurz
mkurz force-pushed the fix/redirect-body-replay branch from 8176027 to 6268d4d Compare August 27, 2026 22:14
@mkurz
mkurz requested a review from hyperxpro August 27, 2026 22:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants