Skip to content

fix(netty): preserve QUERY across redirects - #2317

Open
mkurz wants to merge 1 commit into
AsyncHttpClient:mainfrom
mkurz:fix/query-redirects
Open

fix(netty): preserve QUERY across redirects#2317
mkurz wants to merge 1 commit into
AsyncHttpClient:mainfrom
mkurz:fix/query-redirects

Conversation

@mkurz

@mkurz mkurz commented Aug 26, 2026

Copy link
Copy Markdown

Summary

  • Preserve the QUERY method and request content when following 301, 302, 307, and 308 redirects, as required by RFC 10008.
  • Keep the existing 303 behavior, which follows the redirect with GET and drops the request content.
  • Add a HttpConstants.Methods.QUERY constant and name the redirect-policy decisions explicitly.
  • Cover every QUERY redirect status, strict and non-strict 302 handling, existing POST behavior, repeatable generated bodies, and cross-origin redirects.

Problem

Redirect30xInterceptor treated every method other than GET, HEAD, and OPTIONS like POST when handling 301 and non-strict 302 responses. As a result, a QUERY request was changed to GET and its query content and Content-Type were dropped.

RFC 10008 section 2.5 explicitly says that the POST-to-GET exceptions for 301 and 302 do not apply to QUERY. A QUERY request must instead be repeated with its content for 301, 302, 307, and 308. Only a 303 response calls for a GET request to the redirect target.

Change

Recognize QUERY in the redirect policy so 301 and 302 retain the original method and body. The existing 307, 308, 303, HEAD, OPTIONS, and POST behavior is unchanged. The implementation uses named decisions rather than embedding the QUERY exception in one compound expression.

Cross-origin QUERY redirects retain their method, content, and Content-Type, as required to repeat the query. This means a QUERY body now crosses origins on 301 and 302 where the old, incorrect GET rewrite dropped it. AHC already uses that body-replay trust model for 307 and 308. Existing redirect security still strips Authorization, Realm credentials, and user-supplied Cookie headers before sending the request to the new origin. A separate future policy for suppressing cross-origin request bodies could be considered, but this conformance fix does not add one.

The broader pre-existing behavior that converts PUT, PATCH, DELETE, and other methods to GET after 301 and non-strict 302 responses is deliberately out of scope. It changes established behavior for existing users and is handled in a separate follow-up pull request.

That follow-up handles QUERY as an ordinary non-POST method and therefore supersedes this pull request's QUERY-specific interceptor condition if both land. Keeping this narrow pull request separate still allows the standardized QUERY behavior to land even if the broader compatibility change is rejected; the public method constant and QUERY-specific regression coverage remain useful either way.

This adds the public HttpConstants.Methods.QUERY string constant. It is an additive, user-facing API for constructing QUERY requests; there is no incompatible 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

  • Reproduced the failure on JDK 11 with ./mvnw -pl client -Dtest=RedirectBodyTest#query301KeepsMethodAndBody test.
  • ./mvnw -pl client -Dtest=RedirectBodyTest test on JDK 11.
  • ./mvnw -pl client -Dtest=RedirectBodyTest,RedirectCredentialSecurityTest test on JDK 11: 34 tests passed.
  • ./mvnw clean verify on JDK 11: BUILD SUCCESS (full reactor, including tests, Javadocs, artifact signing, coverage, and Revapi).

Generated with OpenAI Codex.

RFC 10008 requires QUERY requests to retain their method and content
across 301, 302, 307, and 308 redirects. AHC treated QUERY like POST
on 301 and non-strict 302, changing it to GET and dropping its content.

Preserve QUERY while keeping the established POST and 303 behavior.
Expose the standardized method constant and cover every redirect status,
strict 302, repeatable bodies, and cross-origin credential stripping.

OpenAI Codex on behalf of Matthias Kurz.

Co-Authored-By: OpenAI Codex <codex@openai.com>
@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.

Comment on lines +124 to +126
(statusCode == SEE_OTHER_303 || (!isQuery && legacyRedirectToGet));
boolean keepBody = queryRedirect ||
statusCode == TEMPORARY_REDIRECT_307 || statusCode == PERMANENT_REDIRECT_308 ||

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.

This sends the QUERY body to whatever host the 301 points at. We strip Authorization and Cookie when the origin changes, but with QUERY the body is the query, so a cached or injected 301 gets the whole thing where before it got an empty GET. 307 and 308 already behave like this, but 301 and 302 are the cacheable ones. Can we require sameBase for the body here, or gate it on a config flag?

boolean switchToGet = !methodAlreadyPreserved &&
(statusCode == SEE_OTHER_303 || (!isQuery && legacyRedirectToGet));
boolean keepBody = queryRedirect ||
statusCode == TEMPORARY_REDIRECT_307 || statusCode == PERMANENT_REDIRECT_308 ||

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.

The block this feeds, at line 158, covers six of the body kinds. getFile(), getStreamData(), getByteBufData() and getCompositeByteData() all fall through, and QUERY is not POST/PUT/PATCH so we do not set ZERO_CONTENT_LENGTH either. A QUERY with a File body then gets redirected with its Content-Type and no content at all, which is the case this PR is meant to fix. It is pre existing for 307 and 308, but this PR is what makes it reachable for QUERY.

Its order does not match NettyRequestFactory.body() either. That one takes byteData before formParams and bodyParts before bodyGenerator, we do the reverse, so a request carrying both sends a different body after the redirect than it did on the first hop.

Comment on lines +118 to +121
boolean strict302 = statusCode == FOUND_302 && config.isStrict302Handling();
boolean queryRedirect = isQuery &&
(statusCode == MOVED_PERMANENTLY_301 || statusCode == FOUND_302);
boolean legacyRedirectToGet = statusCode == MOVED_PERMANENTLY_301 ||

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.

queryRedirect and legacyRedirectToGet are each read once, on the next line. methodAlreadyPreserved also reads backwards: it is false for QUERY, but the method is preserved for QUERY on 301. Did you consider scoping the rewrite to POST instead? RFC 9110 only defines it for POST, so QUERY would need no special case at all. It changes PUT and DELETE too so probably its own PR, but then you are not adding a branch you have to take out again later.

@@ -111,11 +112,19 @@ public boolean exitAfterHandlingRedirect(Channel channel, NettyResponseFuture<?>
future.setScramContext(null);

String originalMethod = request.getMethod();

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 you put the RFC 10008 2.5 reference in a comment here? Every other odd thing in this method says why it is there, and this is the least obvious line in it.

Comment on lines +115 to +116
boolean isQuery = QUERY.equals(originalMethod);
boolean methodAlreadyPreserved = GET.equals(originalMethod) ||

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.

Nit: these three read originalMethod.equals(GET) before. getMethod() is never null, so can we leave them as they were and keep the diff to the actual change?

public static final String PATCH = HttpMethod.PATCH.name();
public static final String POST = HttpMethod.POST.name();
public static final String PUT = HttpMethod.PUT.name();
public static final String QUERY = "QUERY";

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.

Netty 4.2.17 already has HttpMethod.QUERY, so this can be derived like the others.

Suggested change
public static final String QUERY = "QUERY";
public static final String QUERY = HttpMethod.QUERY.name();

}

@RepeatedIfExceptionsTest(repeats = 5)
public void regular308KeepsBody() throws Exception {

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.

The condition you rewrote also drives PUT, PATCH and DELETE, but every new test is POST or QUERY. One parameterized test over those three on 301 and 302 would pin the behavior the PR says it is not touching.

}

@RepeatedIfExceptionsTest(repeats = 5)
public void query301KeepsRepeatableBodyGenerator() throws Exception {

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 get one with a non repeatable generator too? A QUERY on 301 used to go out as a bodiless GET and complete, now it replays a consumed stream and fails the future through the guard from #2312. ByteArrayBodyGenerator is repeatable so this test does not see it.

"Cookie must be stripped on a cross-origin QUERY redirect");
assertEquals(QUERY, query301MethodOnTarget.get());
assertEquals("application/query", query301ContentTypeOnTarget.get());
assertEquals("sensitive-query", query301BodyOnTarget.get());

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.

This locks in the cross origin body replay I asked about in the interceptor. If we keep it that is fine, but I would rather decide that first than have a test assert it.

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