fix(netty): preserve QUERY across redirects - #2317
Conversation
|
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. |
| (statusCode == SEE_OTHER_303 || (!isQuery && legacyRedirectToGet)); | ||
| boolean keepBody = queryRedirect || | ||
| statusCode == TEMPORARY_REDIRECT_307 || statusCode == PERMANENT_REDIRECT_308 || |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Agreed that this needs an explicit policy decision. My preference for this conformance fix is to retain AHC’s existing keep-body redirect policy: AHC already replays request bodies across origins for 307, 308, and strict 302 while stripping credentials.
Requiring sameBase only for QUERY on 301/302 cannot safely mean dropping the body: that would preserve the QUERY method while silently changing the query itself, contrary to RFC 10008.
On the config-flag option: I think that is the right mechanism if we want this restriction, but it should apply uniformly to every cross-origin keep-body redirect, not only QUERY on 301/302, and it should refuse the redirect rather than strip the body. Your caching point is well taken: when a 301/302 response is cached, it can repeatedly cause cross-origin body replay without a new redirect response. That strengthens the case for a configurable cross-origin body-replay policy; I do not think it argues for QUERY following a different policy from every other body-bearing method.
Would you prefer that uniform policy to be designed as part of this PR, or is retaining AHC’s existing trust model acceptable here while we discuss the broader policy separately?
There was a problem hiding this comment.
Fine by me, let's keep the existing trust model here. Two things I got wrong above: 301 and 308 are the heuristically cacheable ones, not 301 and 302, and we already replay on 308, so caching isn't the difference. And forget the sameBase idea, you were right, it would keep the method and drop the body. My worry is just that 301 and 302 are what a redirect helper emits by default, so that's what an open redirect gives you, while 307 and 308 have to be asked for. But that's about the interceptor in general, not this PR. I'll open a separate issue.
| boolean strict302 = statusCode == FOUND_302 && config.isStrict302Handling(); | ||
| boolean queryRedirect = isQuery && | ||
| (statusCode == MOVED_PERMANENTLY_301 || statusCode == FOUND_302); | ||
| boolean legacyRedirectToGet = statusCode == MOVED_PERMANENTLY_301 || |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I have a local follow-up branch which already implements this: the legacy rewrite is scoped to POST, PUT/PATCH/DELETE/custom methods are tested on 301 and 302, and the QUERY-specific condition in this PR becomes unnecessary.
There are two possible ways to sequence it: land this narrow PR and follow it with the broader change, or close this PR and open the broader one with the QUERY constant and regression tests carried over. I lean toward the latter given your point about not adding a branch that is removed again, but the narrow PR would allow QUERY conformance to land independently if the compatibility change for existing PUT/PATCH/DELETE users needs more discussion. Which would you prefer?
There was a problem hiding this comment.
I'd keep this one. Your follow up also makes PUT, PATCH and DELETE keep their bodies on 301 and 302, and that's a much bigger change to behaviour that's been there since 2015. QUERY is new so nobody depends on it yet, it's the safer one to go first. Also for the squash message, the PR body mentions a copyBody helper from #2316 but there's no such method, it landed as request.toBuilder().
| "Cookie must be stripped on a cross-origin QUERY redirect"); | ||
| assertEquals(QUERY, query301MethodOnTarget.get()); | ||
| assertEquals("application/query", query301ContentTypeOnTarget.get()); | ||
| assertEquals("sensitive-query", query301BodyOnTarget.get()); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Agreed. The test is intended to make the selected policy explicit, not decide it implicitly. If we retain AHC’s existing cross-origin keep-body policy, it should remain because it verifies both request preservation and credential stripping. If you prefer the uniform configuration discussed in the interceptor thread to be added here, I will update the implementation and this test together.
This comment was marked as outdated.
This comment was marked as outdated.
b8c4db4 to
4596aa0
Compare
This comment was marked as outdated.
This comment was marked as outdated.
|
@hyperxpro I have addressed all concrete review feedback in five follow-up commits and rerun Two policy decisions remain:
Please let me know which scope you prefer and whether retaining the existing cross-origin policy is acceptable here. |
| (statusCode == SEE_OTHER_303 || (!isQuery && legacyRedirectToGet)); | ||
| boolean keepBody = queryRedirect || | ||
| statusCode == TEMPORARY_REDIRECT_307 || statusCode == PERMANENT_REDIRECT_308 || |
There was a problem hiding this comment.
Fine by me, let's keep the existing trust model here. Two things I got wrong above: 301 and 308 are the heuristically cacheable ones, not 301 and 302, and we already replay on 308, so caching isn't the difference. And forget the sameBase idea, you were right, it would keep the method and drop the body. My worry is just that 301 and 302 are what a redirect helper emits by default, so that's what an open redirect gives you, while 307 and 308 have to be asked for. But that's about the interceptor in general, not this PR. I'll open a separate issue.
| boolean strict302 = statusCode == FOUND_302 && config.isStrict302Handling(); | ||
| boolean queryRedirect = isQuery && | ||
| (statusCode == MOVED_PERMANENTLY_301 || statusCode == FOUND_302); | ||
| boolean legacyRedirectToGet = statusCode == MOVED_PERMANENTLY_301 || |
There was a problem hiding this comment.
I'd keep this one. Your follow up also makes PUT, PATCH and DELETE keep their bodies on 301 and 302, and that's a much bigger change to behaviour that's been there since 2015. QUERY is new so nobody depends on it yet, it's the safer one to go first. Also for the squash message, the PR body mentions a copyBody helper from #2316 but there's no such method, it landed as request.toBuilder().
69393b5 to
0a6b003
Compare
|
Since I can no longer reply inline after the force-push:
Thanks for clarifying, and for taking the general follow-up issue. I kept AHC's existing cross-origin keep-body trust model unchanged in this PR. The branch is now rebased onto latest
Agreed. I kept this PR narrowly scoped to QUERY; the existing PUT, PATCH, and DELETE behavior remains unchanged and is pinned by regression tests. The broader POST-only rewrite remains a separate follow-up. Also corrected: #2316 landed the replay preservation through The branch is now rebased onto latest |
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 established behavior for other methods. Add the standardized method constant and regression coverage for every redirect status, strict 302, repeatable and non-repeatable bodies, and cross-origin credential stripping. Cross-origin QUERY redirects retain AHC's existing keep-body trust model. A broader change limiting legacy redirect rewrites to POST remains a separate compatibility decision. OpenAI Codex on behalf of Matthias Kurz. Co-Authored-By: OpenAI Codex <codex@openai.com>
0a6b003 to
94bacf1
Compare
Summary
HttpConstants.Methods.QUERYconstant and name the redirect-policy decisions explicitly.Problem
Redirect30xInterceptortreated 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, representation-independent policy that refuses cross-origin keep-body redirects could be considered, but this conformance fix does not currently 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 branch,
fix/non-post-redirects.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.QUERYstring 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 commits include
Co-Authored-By: OpenAI Codex <codex@openai.com>perAGENTS.md.Test plan
./mvnw -pl client -Dtest=RedirectBodyTest#query301KeepsMethodAndBody test../mvnw -pl client -Dtest=RedirectBodyTest teston JDK 11../mvnw -pl client -Dtest=RedirectBodyTest,RedirectCredentialSecurityTest teston JDK 11: 57 tests passed../mvnw clean verifyon JDK 11: BUILD SUCCESS (full reactor, including tests, Javadocs, artifact signing, coverage, and Revapi).Generated with OpenAI Codex.