Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,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.ResponseStatusCodes.FOUND_302;
import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.MOVED_PERMANENTLY_301;
import static org.asynchttpclient.util.HttpConstants.ResponseStatusCodes.PERMANENT_REDIRECT_308;
Expand Down Expand Up @@ -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.

boolean switchToGet = !originalMethod.equals(GET) &&
!originalMethod.equals(OPTIONS) &&
!originalMethod.equals(HEAD) &&
(statusCode == MOVED_PERMANENTLY_301 || statusCode == SEE_OTHER_303 || statusCode == FOUND_302 && !config.isStrict302Handling());
boolean keepBody = statusCode == TEMPORARY_REDIRECT_307 || statusCode == PERMANENT_REDIRECT_308 || statusCode == FOUND_302 && config.isStrict302Handling();
boolean isQuery = QUERY.equals(originalMethod);
boolean methodAlreadyPreserved = GET.equals(originalMethod) ||
Comment on lines +115 to +116

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?

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

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.

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

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?

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.

strict302;

HttpHeaders responseHeaders = response.headers();
String location = responseHeaders.get(LOCATION);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public static final class Methods {
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();

public static final String TRACE = HttpMethod.TRACE.name();

private Methods() {
Expand Down
118 changes: 118 additions & 0 deletions client/src/test/java/org/asynchttpclient/RedirectBodyTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,36 @@
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.io.IOUtils;
import org.asynchttpclient.request.body.generator.ByteArrayBodyGenerator;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.handler.AbstractHandler;
import org.junit.jupiter.api.BeforeEach;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;

import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_TYPE;
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.util.HttpConstants.Methods.GET;
import static org.asynchttpclient.util.HttpConstants.Methods.POST;
import static org.asynchttpclient.util.HttpConstants.Methods.QUERY;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

public class RedirectBodyTest extends AbstractBasicTest {

private static volatile boolean redirectAlreadyPerformed;
private static volatile String receivedContentType;
private static volatile String receivedMethod;

@BeforeEach
public void setUp() {
redirectAlreadyPerformed = false;
receivedContentType = null;
receivedMethod = null;
}

@Override
Expand All @@ -59,6 +66,7 @@ public void handle(String pathInContext, Request request, HttpServletRequest htt

} else {
receivedContentType = request.getContentType();
receivedMethod = request.getMethod();
httpResponse.setStatus(200);
int len = request.getContentLength();
httpResponse.setContentLength(len);
Expand All @@ -82,6 +90,7 @@ public void regular301LosesBody() throws Exception {

Response response = c.preparePost(getTargetUrl()).setHeader(CONTENT_TYPE, contentType).setBody(body).setHeader("X-REDIRECT", "301").execute().get(TIMEOUT, TimeUnit.SECONDS);
assertEquals(response.getResponseBody(), "");
assertEquals(GET, receivedMethod);
assertNull(receivedContentType);
}
}
Expand All @@ -94,6 +103,7 @@ public void regular302LosesBody() throws Exception {

Response response = c.preparePost(getTargetUrl()).setHeader(CONTENT_TYPE, contentType).setBody(body).setHeader("X-REDIRECT", "302").execute().get(TIMEOUT, TimeUnit.SECONDS);
assertEquals(response.getResponseBody(), "");
assertEquals(GET, receivedMethod);
assertNull(receivedContentType);
}
}
Expand All @@ -106,10 +116,24 @@ public void regular302StrictKeepsBody() throws Exception {

Response response = c.preparePost(getTargetUrl()).setHeader(CONTENT_TYPE, contentType).setBody(body).setHeader("X-REDIRECT", "302").execute().get(TIMEOUT, TimeUnit.SECONDS);
assertEquals(response.getResponseBody(), body);
assertEquals(POST, receivedMethod);
assertEquals(receivedContentType, contentType);
}
}

@RepeatedIfExceptionsTest(repeats = 5)
public void regular303SwitchesToGetAndLosesBody() throws Exception {
try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) {
String body = "hello there";
String contentType = "text/plain; charset=UTF-8";

Response response = c.preparePost(getTargetUrl()).setHeader(CONTENT_TYPE, contentType).setBody(body).setHeader("X-REDIRECT", "303").execute().get(TIMEOUT, TimeUnit.SECONDS);
assertEquals("", response.getResponseBody());
assertEquals(GET, receivedMethod);
assertNull(receivedContentType);
}
}

@RepeatedIfExceptionsTest(repeats = 5)
public void regular307KeepsBody() throws Exception {
try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) {
Expand All @@ -118,7 +142,101 @@ public void regular307KeepsBody() throws Exception {

Response response = c.preparePost(getTargetUrl()).setHeader(CONTENT_TYPE, contentType).setBody(body).setHeader("X-REDIRECT", "307").execute().get(TIMEOUT, TimeUnit.SECONDS);
assertEquals(response.getResponseBody(), body);
assertEquals(POST, receivedMethod);
assertEquals(receivedContentType, contentType);
}
}

@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.

try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) {
String body = "hello there";
String contentType = "text/plain; charset=UTF-8";

Response response = c.preparePost(getTargetUrl()).setHeader(CONTENT_TYPE, contentType).setBody(body).setHeader("X-REDIRECT", "308").execute().get(TIMEOUT, TimeUnit.SECONDS);
assertEquals(body, response.getResponseBody());
assertEquals(POST, receivedMethod);
assertEquals(contentType, receivedContentType);
}
}

@RepeatedIfExceptionsTest(repeats = 5)
public void query301KeepsMethodAndBody() throws Exception {
queryRedirectKeepsMethodAndBody(301, false);
}

@RepeatedIfExceptionsTest(repeats = 5)
public void query302KeepsMethodAndBody() throws Exception {
queryRedirectKeepsMethodAndBody(302, false);
}

@RepeatedIfExceptionsTest(repeats = 5)
public void query302StrictKeepsMethodAndBody() throws Exception {
queryRedirectKeepsMethodAndBody(302, true);
}

@RepeatedIfExceptionsTest(repeats = 5)
public void query303SwitchesToGetAndDropsBody() throws Exception {
try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) {
String body = "hello there";
String contentType = "text/plain; charset=UTF-8";

Response response = c.prepare(QUERY, getTargetUrl())
.setHeader(CONTENT_TYPE, contentType)
.setBody(body)
.setHeader("X-REDIRECT", "303")
.execute()
.get(TIMEOUT, TimeUnit.SECONDS);
assertEquals("", response.getResponseBody());
assertEquals(GET, receivedMethod);
assertNull(receivedContentType);
}
}

@RepeatedIfExceptionsTest(repeats = 5)
public void query307KeepsMethodAndBody() throws Exception {
queryRedirectKeepsMethodAndBody(307, false);
}

@RepeatedIfExceptionsTest(repeats = 5)
public void query308KeepsMethodAndBody() throws Exception {
queryRedirectKeepsMethodAndBody(308, false);
}

@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.

try (AsyncHttpClient c = asyncHttpClient(config().setFollowRedirect(true))) {
byte[] body = "hello there".getBytes(StandardCharsets.UTF_8);
String contentType = "text/plain; charset=UTF-8";

Response response = c.prepare(QUERY, getTargetUrl())
.setHeader(CONTENT_TYPE, contentType)
.setBody(new ByteArrayBodyGenerator(body))
.setHeader("X-REDIRECT", "301")
.execute()
.get(TIMEOUT, TimeUnit.SECONDS);
assertEquals("hello there", response.getResponseBody());
assertEquals(QUERY, receivedMethod);
assertEquals(contentType, receivedContentType);
}
}

private void queryRedirectKeepsMethodAndBody(int statusCode, boolean strict302Handling) throws Exception {
try (AsyncHttpClient c = asyncHttpClient(config()
.setFollowRedirect(true)
.setStrict302Handling(strict302Handling))) {
String body = "hello there";
String contentType = "text/plain; charset=UTF-8";

Response response = c.prepare(QUERY, getTargetUrl())
.setHeader(CONTENT_TYPE, contentType)
.setBody(body)
.setHeader("X-REDIRECT", Integer.toString(statusCode))
.execute()
.get(TIMEOUT, TimeUnit.SECONDS);
assertEquals(body, response.getResponseBody());
assertEquals(QUERY, receivedMethod);
assertEquals(contentType, receivedContentType);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import java.util.concurrent.atomic.AtomicReference;

import static org.asynchttpclient.Dsl.basicAuthRealm;
import static org.asynchttpclient.util.HttpConstants.Methods.QUERY;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
Expand Down Expand Up @@ -64,6 +65,11 @@ public class RedirectCredentialSecurityTest {
private static final AtomicReference<String> bodyOn307Target = new AtomicReference<>();
private static final AtomicReference<String> authOn308Target = new AtomicReference<>();
private static final AtomicReference<String> bodyOn308Target = new AtomicReference<>();
private static final AtomicReference<String> query301AuthOnTarget = new AtomicReference<>();
private static final AtomicReference<String> query301CookieOnTarget = new AtomicReference<>();
private static final AtomicReference<String> query301ContentTypeOnTarget = new AtomicReference<>();
private static final AtomicReference<String> query301MethodOnTarget = new AtomicReference<>();
private static final AtomicReference<String> query301BodyOnTarget = new AtomicReference<>();
private static final AtomicReference<String> lastCookieHeaderOnA = new AtomicReference<>();
private static final AtomicReference<String> lastCookieHeaderOnB = new AtomicReference<>();
private static final AtomicReference<String> cookieAtChainStep2 = new AtomicReference<>();
Expand Down Expand Up @@ -186,6 +192,24 @@ public static void startServers() throws Exception {
exchange.close();
});

serverA.createContext("/redirect-query-301-to-b", exchange -> {
exchange.getRequestBody().readAllBytes();
exchange.getResponseHeaders().add("Location", "http://127.0.0.1:" + portB + "/target-query-301");
exchange.sendResponseHeaders(301, -1);
exchange.close();
});

serverB.createContext("/target-query-301", exchange -> {
query301AuthOnTarget.set(exchange.getRequestHeaders().getFirst("Authorization"));
query301CookieOnTarget.set(exchange.getRequestHeaders().getFirst("Cookie"));
query301ContentTypeOnTarget.set(exchange.getRequestHeaders().getFirst("Content-Type"));
query301MethodOnTarget.set(exchange.getRequestMethod());
query301BodyOnTarget.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"));
Expand Down Expand Up @@ -508,6 +532,36 @@ void redirect308CrossDomainStripsAuthButPreservesBody() throws Exception {
}
}

@Test
void query301CrossOriginStripsCredentialsAndPreservesRequest() throws Exception {
DefaultAsyncHttpClientConfig config = new DefaultAsyncHttpClientConfig.Builder()
.setFollowRedirect(true)
.build();
try (DefaultAsyncHttpClient client = new DefaultAsyncHttpClient(config)) {
query301AuthOnTarget.set(null);
query301CookieOnTarget.set(null);
query301ContentTypeOnTarget.set(null);
query301MethodOnTarget.set(null);
query301BodyOnTarget.set(null);

client.prepare(QUERY, "http://127.0.0.1:" + portA + "/redirect-query-301-to-b")
.setHeader("Authorization", "Bearer secret-token")
.setHeader("Cookie", "session=secret-session")
.setHeader("Content-Type", "application/query")
.setBody("sensitive-query")
.execute()
.get(5, TimeUnit.SECONDS);

assertNull(query301AuthOnTarget.get(),
"Authorization must be stripped on a cross-origin QUERY redirect");
assertNull(query301CookieOnTarget.get(),
"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.

}
}

/**
* Cross-domain redirect (different port) must strip a user-supplied Cookie header.
* Regression test for GHSA-fmxf-pm6p-7xgm.
Expand Down
Loading