fix(netty): preserve non-POST redirect methods - #2325
Conversation
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 <codex@openai.com>
| (statusCode == SEE_OTHER_303 || | ||
| (isPost && (statusCode == MOVED_PERMANENTLY_301 || | ||
| (statusCode == FOUND_302 && !strict302)))); | ||
| boolean keepBody = (!bodylessMethod && !isPost && |
There was a problem hiding this comment.
schemeDowngrade is computed below but only feeds stripAuth, so an https to http 301 now replays the body in cleartext. The JDK client refuses that hop completely. Can we gate keepBody on the scheme as well ? 307 and 308 have the same problem already so maybe that part is a follow up.
There was a problem hiding this comment.
Thanks, this is a real concern. I did not gate keepBody on the scheme because that would preserve PUT/QUERY/etc. while silently deleting their payload, which recreates the data-corruption shape this change is intended to remove. If AHC restricts HTTPS-to-HTTP body replay, I think it should refuse the redirect itself and apply the policy uniformly to every keep-body redirect, including strict 302, 307, and 308, which can already replay bodies on a downgrade.
I updated the PR description to disclose that 301/302 can now replay content on a downgrade where the old method/body rewrite discarded it. I agree that a uniform downgrade policy is best handled as a focused follow-up rather than changing only these newly corrected paths here.
| boolean isQuery = QUERY.equals(originalMethod); | ||
| boolean methodAlreadyPreserved = originalMethod.equals(GET) || | ||
| boolean isPost = originalMethod.equals(POST); | ||
| boolean bodylessMethod = originalMethod.equals(GET) || |
There was a problem hiding this comment.
bodylessMethod is not accurate, we do support GET and OPTIONS with a body, see BasicHttpTest.getShouldAllowBody. So the same GET keeps its body on 307 but loses it on 301. Neither changes the method, so why drop it ?
There was a problem hiding this comment.
Fixed in ba32a32. I renamed bodylessMethod to methodAlreadyPreserved and now derive body handling from the redirect decision: the body is kept whenever the method is preserved, except for 303. I added 301/302 coverage for GET, HEAD, and OPTIONS with explicit bodies; all three retain their method, content type, and bytes.
| (isPost && (statusCode == MOVED_PERMANENTLY_301 || | ||
| (statusCode == FOUND_302 && !strict302)))); | ||
| boolean keepBody = (!bodylessMethod && !isPost && | ||
| (statusCode == MOVED_PERMANENTLY_301 || statusCode == FOUND_302)) || |
There was a problem hiding this comment.
REDIRECT_STATUSES is public and mutable, so a registered 300 keeps the method here but loses the body and you get a PUT with Content-Length: 0. Should this keep the body for anything that is not 303 and not POST, instead of listing the two ?
There was a problem hiding this comment.
Fixed in ba32a32. I used statusCode != SEE_OTHER_303 && !switchToGet for keepBody, which is slightly broader than "not 303 and not POST": it keeps the body whenever the method is preserved. That distinction matters for a caller-added 300 with POST; excluding POST there would retain POST while sending Content-Length: 0, the same corruption we want to avoid. The regression test temporarily registers 300 and covers both POST and PUT, asserting the original method, content type, and body bytes.
| 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. |
There was a problem hiding this comment.
Nit: can we keep the RFC 10008 reference ? QUERY is still correct after this but only as a side effect of !isPost now.
There was a problem hiding this comment.
Kept in ba32a32. The policy comment now cites RFC 9110 for the POST-scoped historical rewrite and RFC 10008 section 2.5 for QUERY, even though QUERY is handled by the general non-POST rule.
| "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()); |
There was a problem hiding this comment.
This pins the body being replayed cross origin as expected behaviour, under a name that reads like a hardening test. Can we rename it to say what it asserts ?
There was a problem hiding this comment.
Renamed in 2710101 to put301CrossOriginReplaysBodyAndStripsCredentials, so the test name states both sides of the behavior it pins rather than reading only as a hardening test.
| .execute() | ||
| .get(5, TimeUnit.SECONDS); | ||
|
|
||
| assertNull(put301AuthOnTarget.get(), |
There was a problem hiding this comment.
These pass even if we stopped sending Authorization on the first hop. crossDomainRedirectStripsCookieHeader records the value on A and asserts it first, we should do the same here.
There was a problem hiding this comment.
Fixed in 2710101. The origin handler now records Authorization and Cookie independently of the target handler, and the test first asserts the exact values on the original PUT before asserting that both are absent from the redirected request. It therefore fails if either credential was never sent on the first hop.
| "CUSTOM, 302" | ||
| }) | ||
| public void putPatchAndDelete301And302KeepExistingBehavior(String method, int statusCode) throws Exception { | ||
| public void nonPost301And302KeepMethodAndBody(String method, int statusCode) throws Exception { |
There was a problem hiding this comment.
These all use getTargetUrl() so every case here is same origin, which is also why the suite stays green. Can we add a row that redirects to a different host ?
There was a problem hiding this comment.
Added a dedicated different-host case in 2710101. It starts the PUT at 127.0.0.1 and follows the 301 Location to localhost, then asserts that the redirected request retains PUT, its content type, and its body bytes. I kept this as a separate test rather than adding an origin flag to every method/status row so the origin-boundary condition is explicit. The security test separately verifies credential stripping across a different-port origin.
|
|
||
| @ParameterizedTest(name = "{0} on {1} keeps the existing GET rewrite") | ||
| @RepeatedIfExceptionsTest(repeats = 5) | ||
| public void put301WithNonRepeatableBodyGeneratorFailsPromptly() throws Exception { |
There was a problem hiding this comment.
ensureBodyReplayable only looks at InputStreamPart and File, so this one fails in NettyInputStreamBody.write after we already connected to the target. Can we check streamData and InputStreamBodyGenerator up front too ?
There was a problem hiding this comment.
Fixed in two commits. 872800a centralizes the selected body representation using the exact precedence from NettyRequestFactory.body() and routes multipart, file, and length checks through it. That also fixed a false rejection in the merged #2316 code: a stale lower-priority multipart InputStreamPart no longer rejects a redirect when the selected body is a replayable byte array, with a regression test for that coexistence case.
78930e8dc then preflights the selected raw streamData or InputStreamBodyGenerator. If it reports no mark/reset support, the interceptor now fails before connecting to the redirect target with Redirect request body InputStream does not support mark/reset and cannot be replayed.
The preflight is necessarily partial: a stream can advertise mark support but still be closed or fail to reset after the first write, so the existing write-time replay guard remains the final authority. A generic BodyGenerator can also conceal replayability until its one-shot createBody() is called, so that path retains its existing write-time behavior. The affected tests now assert the redirect-level preflight message.
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 <codex@openai.com>
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 <codex@openai.com>
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 <codex@openai.com>
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 <codex@openai.com>
Summary
Problem
Redirect30xInterceptorapplied the historical POST-to-GET behavior for 301 and non-strict 302 responses to every method other than GET, HEAD, and OPTIONS. A PUT, PATCH, DELETE, or extension-method request therefore became a bodyless GET after either redirect.The body decision was also coupled to a fixed list of methods and status codes. GET, HEAD, and OPTIONS requests can mechanically carry bodies in AHC, but those bodies were dropped on 301 and 302 even though the methods were retained. A caller-added status in the public mutable
REDIRECT_STATUSESset likewise retained the method while silently dropping its body.RFC 9110 sections 15.4.2 and 15.4.3 scope the compatibility allowance to changing POST to GET. They do not permit rewriting every other method in the same way. RFC 10008 section 2.5 explicitly requires QUERY not to use the POST exceptions.
Change
Apply the legacy 301/302 rewrite only when the original method is POST. Derive body handling from the method decision: keep the body whenever the method is preserved, except for 303, which drops the body regardless. This differs deliberately from checking whether a request is "not POST": a POST receiving a caller-added status such as 300 also keeps its method and must therefore keep its body.
The resulting behavior is:
This is a deliberate compatibility change. It corrects the standards scope and removes method-preserving, body-dropping combinations while retaining the long-established POST behavior for 301 and non-strict 302.
Redirect-body validation
The replay checks now use one private
BodyRepresentationselection that mirrorsNettyRequestFactory.body()precedence. This matters because some request-builder setters leave lower-priority representations in place. Validation now examines only the body AHC actually sent; for example, a stale multipartInputStreamPartno longer rejects a redirect whose selected body is a byte array.Selected raw
InputStreambodies andInputStreamBodyGeneratorinstances that report no mark/reset support are rejected before AHC connects to the redirect target. This preflight is intentionally partial: a stream may report mark support yet be closed or fail to reset after the first send. The existing write-time replay guard remains the final authority for those cases. A genericBodyGeneratorcan also produce an unknown-length or non-repeatable body, but discovering that would require calling its one-shotcreateBody()early, so its existing write-time behavior is unchanged.Redirect security
For a cross-origin 301 or 302, newly preserved request content is replayed to the redirect target. This follows the body-replay trust model AHC already uses for strict 302, 307, and 308. Existing redirect security continues to strip Authorization, Proxy-Authorization, Realm credentials, user-supplied Cookie headers, and Cookie objects when the origin changes. Tests assert that credentials reach the original server, do not reach the target, and that the method, content type, and body reach the target intact.
The same consideration applies to HTTPS-to-HTTP redirects: this pull request can replay content on 301 or 302 that the previous method/body rewrite discarded. Gating only
keepBodyon a scheme downgrade would preserve the method while silently deleting its payload, reproducing the data-corruption shape this change removes. If AHC adopts a downgrade restriction, it should refuse the redirect itself and apply uniformly to all keep-body statuses, including strict 302, 307, and 308. That transport-policy decision is left to a focused follow-up; this pull request does not alter AHC's existing downgrade policy.History checked
The broad conversion is established behavior rather than a recent accident. Issue #989 requested browser-compatible 301 handling, issue #1042 retained the body drop for POST, and pull request #1736 later exempted HEAD and OPTIONS from method rewriting. This change retains the established POST rule while making body preservation follow the resulting method decision. A search did not find an existing issue or pull request specifically correcting PUT, PATCH, DELETE, extension methods, or caller-added redirect statuses.
Compatibility
There is no public API change.
The following redirect behavior changes intentionally:
InputStreamorInputStreamBodyGeneratorwithout mark/reset support now fails before connecting to the redirect target. For newly preserved non-POST 301/302 requests, this replaces the previous silent success as a bodyless GET with an explicit replay failure. Existing keep-body redirects fail earlier and use the new redirect-level error message. Streams that advertise mark support but cannot actually reset still fail at write time.POST on 301 and non-strict 302, strict-302 method policy, 303 method policy, and 307/308 method policy remain unchanged.
AI disclosure
OpenAI Codex on behalf of Matthias Kurz. The commits include
Co-Authored-By: OpenAI Codex <codex@openai.com>perAGENTS.md.Test plan
InputStreamPartfalse rejection before validation was aligned with outbound-body precedence../mvnw -pl client -Dtest=RedirectBodyTest,RedirectCredentialSecurityTest teston JDK 11: 71 tests passed../mvnw clean verifyon JDK 11: BUILD SUCCESS (full reactor, including tests, Javadocs, artifact signing, coverage, and Revapi).Generated with OpenAI Codex.