Skip to content
Merged
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 @@ -8,6 +8,7 @@
import com.blazemeter.jmeter.http2.core.jetty.custom.http3.CustomClientConnectionFactoryOverHTTP3;
import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler;
import com.blazemeter.jmeter.http2.util.BzmHttpPluginProperties;
import com.blazemeter.jmeter.http2.util.Rfc9110Redirects;
import com.github.luben.zstd.ZstdInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
Expand Down Expand Up @@ -4022,7 +4023,12 @@ private void setResultContentResponse(HTTPSampleResult result,
result.setSuccessful(
contentResponse.getStatus() >= 200 && contentResponse.getStatus() <= 399);
result.setResponseHeaders(extractResponseHeaders(contentResponse, responseMessage));
if (result.isRedirect()) {
// Use the RFC 9110-correct redirect check (see Rfc9110Redirects), not result.isRedirect():
// JMeter 5.6.3's version misses 307 for non-GET/HEAD methods, which would otherwise leave
// redirectLocation unset and break HTTP2Sampler.followRedirects()/resultProcessing() for
// that case.
if (Rfc9110Redirects.useLegacyMethodHandling() ? result.isRedirect()
: Rfc9110Redirects.isRedirect(result.getResponseCode())) {
result.setRedirectLocation(extractRedirectLocation(contentResponse));
}

Expand Down
111 changes: 111 additions & 0 deletions src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
import com.blazemeter.jmeter.http2.core.JmeterHttpClientExceptionMapper;
import com.blazemeter.jmeter.http2.core.ProtocolErrorException;
import com.blazemeter.jmeter.http2.util.BzmHttpPluginProperties;
import com.blazemeter.jmeter.http2.util.Rfc9110Redirects;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.helger.commons.annotation.VisibleForTesting;
import java.io.IOException;
import java.lang.reflect.Field;
import java.net.MalformedURLException;
import java.net.URISyntaxException;
Expand Down Expand Up @@ -757,11 +759,120 @@ private HTTP2JettyClient getClient() throws Exception {
: buildClient();
}

/**
* JMeter 5.6.3's {@code HTTPSampleResult.isRedirect()} only recognizes a {@code 307} as a
* redirect for GET/HEAD, so a POST/PUT/PATCH/DELETE + 307 is returned as-is by the inherited
* {@code resultProcessing()} - {@link #followRedirects} below is never even reached. See
* {@link Rfc9110Redirects} for the fix this ports (apache/jmeter PR #6658) and why it's applied
* this way instead of overriding {@code HTTPSampleResult.isRedirect()} directly. This drives
* the follow-up itself only for that one gap, then hands off to the inherited
* {@code resultProcessing()} (marking {@code areFollowingRedirect=true}) for everything else
* (embedded resources, etc.) exactly as it would normally run. Falls back to the unfixed
* inherited behavior when {@link Rfc9110Redirects#useLegacyMethodHandling()}.
*/
@Override
public HTTPSampleResult resultProcessing(final boolean pAreFollowingRedirect,
final int frameDepth, final HTTPSampleResult pRes) {
if (!Rfc9110Redirects.useLegacyMethodHandling()
&& !pAreFollowingRedirect
&& !pRes.isRedirect()
&& Rfc9110Redirects.isRedirect(pRes.getResponseCode())
&& getFollowRedirects()) {
HTTPSampleResult followed = followRedirects(pRes, frameDepth);
return super.resultProcessing(true, frameDepth, followed);
}
return super.resultProcessing(pAreFollowingRedirect, frameDepth, pRes);
}

/**
* Ported from {@code HTTPSamplerBase.followRedirects} (JMeter 5.6.3) with the fix from
* apache/jmeter PR #6658 (see {@link Rfc9110Redirects} and {@link #resultProcessing}):
* {@code computeMethodForRedirect} now takes the response code into account so 307/308
* preserve the original method (RFC 9110 sections 15.4.8/15.4.9), instead of always rewriting
* to GET like 301/302/303 do. Uses {@link Rfc9110Redirects#isRedirect} instead of
* {@code HTTPSampleResult.isRedirect()} to decide whether to keep following a redirect chain,
* for the same reason {@link #resultProcessing} avoids relying on that method for 307. Falls
* back to the inherited, unfixed JMeter behavior when
* {@link Rfc9110Redirects#useLegacyMethodHandling()}.
*/
@Override
protected HTTPSampleResult followRedirects(HTTPSampleResult res, int frameDepth) {
if (Rfc9110Redirects.useLegacyMethodHandling()) {
return super.followRedirects(res, frameDepth);
}
HTTPSampleResult totalRes = new HTTPSampleResult(res);
totalRes.addRawSubResult(res);
HTTPSampleResult lastRes = res;

int redirect;
for (redirect = 0; redirect < MAX_REDIRECTS; redirect++) {
boolean invalidRedirectUrl = false;
String location = lastRes.getRedirectLocation();
if (JMeterUtils.getPropDefault("httpsampler.redirect.removeslashdotdot", true)) {
location = ConversionUtils.removeSlashDotDot(location);
}
location = encodeSpaces(location);
String method = computeMethodForRedirect(lastRes.getHTTPMethod(), lastRes.getResponseCode());

try {
URL url = ConversionUtils.makeRelativeURL(lastRes.getURL(), location);
url = ConversionUtils.sanitizeUrl(url).toURL();
HTTPSampleResult tempRes = sample(url, method, true, frameDepth);
if (tempRes != null) {
lastRes = tempRes;
} else {
break;
}
} catch (MalformedURLException | URISyntaxException e) {
errorResult(e, lastRes);
invalidRedirectUrl = true;
}
if (lastRes.getSubResults() != null && lastRes.getSubResults().length > 0) {
for (SampleResult sub : lastRes.getSubResults()) {
totalRes.addSubResult(sub);
}
} else if (!invalidRedirectUrl) {
totalRes.addSubResult(lastRes);
}

if (!Rfc9110Redirects.isRedirect(lastRes.getResponseCode())) {
break;
}
}
if (redirect >= MAX_REDIRECTS) {
lastRes = errorResult(
new IOException("Exceeded maximum number of redirects: " + MAX_REDIRECTS),
new HTTPSampleResult(lastRes));
totalRes.addSubResult(lastRes);
}

totalRes.setSampleLabel(totalRes.getSampleLabel() + "->" + lastRes.getSampleLabel());
totalRes.setURL(lastRes.getURL());
totalRes.setHTTPMethod(lastRes.getHTTPMethod());
totalRes.setQueryString(lastRes.getQueryString());
totalRes.setRequestHeaders(lastRes.getRequestHeaders());
totalRes.setResponseData(lastRes.getResponseData());
totalRes.setResponseCode(lastRes.getResponseCode());
totalRes.setSuccessful(lastRes.isSuccessful());
totalRes.setResponseMessage(lastRes.getResponseMessage());
totalRes.setDataType(lastRes.getDataType());
totalRes.setResponseHeaders(lastRes.getResponseHeaders());
totalRes.setContentType(lastRes.getContentType());
totalRes.setDataEncoding(lastRes.getDataEncodingNoDefault());
return totalRes;
}

private String computeMethodForRedirect(String initialMethod, String responseCode) {
if (HTTPConstants.SC_TEMPORARY_REDIRECT.equals(responseCode)
|| HTTPConstants.SC_PERMANENT_REDIRECT.equals(responseCode)) {
return initialMethod;
}
if (!HTTPConstants.HEAD.equalsIgnoreCase(initialMethod)) {
return HTTPConstants.GET;
}
return initialMethod;
}

static void registerParser(String contentType, String className) {
LOG.info("Parser for {} is {}", contentType, className);
PARSERS_FOR_CONTENT_TYPE.put(contentType, className);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.blazemeter.jmeter.http2.util;

import org.apache.jmeter.protocol.http.util.HTTPConstants;

/**
* Shared by {@code HTTP2Sampler} and {@code HTTP2JettyClient}: JMeter 5.6.3's
* {@code HTTPSampleResult.isRedirect()} only recognizes a {@code 307} response as a redirect
* when the request method is GET or HEAD, and {@code HTTPSamplerBase.computeMethodForRedirect}
* rewrites every redirected method to GET regardless of status code - both violate RFC 9110
* sections 15.4.8/15.4.9, which require 307/308 to preserve the original method (and therefore
* its body). Apache JMeter fixed this upstream in
* <a href="https://github.com/apache/jmeter/pull/6658">apache/jmeter PR #6658</a> (merged
* 2026-03-19), but that fix is not in 5.6.3 or any released JMeter version yet.
*
* <p>Set {@code -Dblazemeter.http.legacyRedirectMethodHandling=true} (or the legacy
* {@code HTTP2Sampler.*}/{@code httpJettyClient.*} equivalents) to opt back into JMeter 5.6.3's
* original behavior, bug included, for users who need byte-for-byte parity with stock JMeter.
*/
public final class Rfc9110Redirects {

private static final String LEGACY_REDIRECT_METHOD_HANDLING_PROPERTY =
"blazemeter.http.legacyRedirectMethodHandling";

private Rfc9110Redirects() {
}

public static boolean useLegacyMethodHandling() {
return BzmHttpPluginProperties.getPropDefault(LEGACY_REDIRECT_METHOD_HANDLING_PROPERTY, false);
}

/** Matches {@code HTTPSampleResult.isRedirect()} post apache/jmeter PR #6658: unlike the
* unfixed JMeter 5.6.3 version, 307 is a redirect for any method, not just GET/HEAD. */
public static boolean isRedirect(String responseCode) {
return HTTPConstants.SC_MOVED_PERMANENTLY.equals(responseCode)
|| HTTPConstants.SC_MOVED_TEMPORARILY.equals(responseCode)
|| HTTPConstants.SC_SEE_OTHER.equals(responseCode)
|| HTTPConstants.SC_TEMPORARY_REDIRECT.equals(responseCode)
|| HTTPConstants.SC_PERMANENT_REDIRECT.equals(responseCode);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_200_ZSTD;
import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_200_WITH_BODY;
import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_302;
import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_302_TO_ECHO;
import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_307_TO_ECHO;
import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_308_TO_ECHO;
import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_400;
import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_401_NO_WWW_AUTHENTICATE;
import static com.blazemeter.jmeter.http2.core.ServerBuilder.SERVER_PATH_BIG_RESPONSE;
Expand Down Expand Up @@ -226,6 +229,134 @@ public void shouldFollowRedirectWhenHeadMethodAndFollowRedirectEnabled() throws
softly.assertThat(result.getResponseDataAsString()).isEmpty();
}

@Test
public void shouldNotResendRequestBodyWhenPostRedirectIsFollowedAsGet() throws Exception {
buildStartedServer();
sampler.setMethod(HTTPConstants.POST);
sampler.setFollowRedirects(true);
sampler.addArgument("test1", TEST_ARGUMENT_1);
sampler.addArgument("test2", TEST_ARGUMENT_2);

HTTPSampleResult result = sample(SERVER_PATH_302_TO_ECHO, HTTPConstants.POST);

softly.assertThat(result.isSuccessful()).isTrue();
softly.assertThat(result.getResponseCode()).isEqualTo("200");
SampleResult[] subResults = result.getSubResults();
softly.assertThat(subResults.length).isGreaterThan(0);
HTTPSampleResult followUp = (HTTPSampleResult) subResults[subResults.length - 1];
softly.assertThat(followUp.getHTTPMethod()).isEqualTo(HTTPConstants.GET);
// SERVER_PATH_302_TO_ECHO redirects to SERVER_PATH_200_WITH_BODY, which echoes back
// any request body it receives. JMeter core rewrites POST to GET when following a
// 302, so the follow-up request must not carry the original POST entity (RFC 9110
// section 15.4.3; matches HTTPSamplerProxy/HttpClient4 behavior).
softly.assertThat(followUp.getResponseDataAsString()).isEmpty();
}

@Test
public void shouldPreserveRequestBodyWhenPostRedirectIsFollowedAs307ViaAutoRedirects()
throws Exception {
// Unlike 301/302/303, RFC 9110 section 15.4.8 requires a 307 redirect to preserve the
// original method and body. With autoRedirects enabled, Jetty's own redirect support
// handles the follow-up (not JMeter's manual loop, see the *ViaFollowRedirectsOnly test
// below), and already implements this correctly - this test locks that in.
buildStartedServer();
sampler.setMethod(HTTPConstants.POST);
sampler.setFollowRedirects(true);
sampler.setAutoRedirects(true);
sampler.addArgument("test1", TEST_ARGUMENT_1);
sampler.addArgument("test2", TEST_ARGUMENT_2);

HTTPSampleResult result = sample(SERVER_PATH_307_TO_ECHO, HTTPConstants.POST);

softly.assertThat(result.isSuccessful()).isTrue();
softly.assertThat(result.getResponseCode()).isEqualTo("200");
softly.assertThat(result.getResponseDataAsString())
.isEqualTo("test1=" + TEST_ARGUMENT_1 + "&test2=" + TEST_ARGUMENT_2);
}

@Test
public void shouldPreserveRequestBodyWhenPostRedirectIsFollowedAs307ViaFollowRedirectsOnly()
throws Exception {
// With autoRedirects OFF, JMeter's own manual follow-redirects loop drives this instead of
// Jetty. Apache JMeter got this wrong until apache/jmeter PR #6658 (merged 2026-03-19, not
// yet in the 5.6.3 release this project targets): HTTPSampleResult.isRedirect() only
// recognized 307 as a redirect for GET/HEAD (a POST+307 was returned as-is, never followed),
// and HTTPSamplerBase.computeMethodForRedirect() rewrote every redirected method to GET
// regardless of status code, dropping the body. HTTP2Sampler.resultProcessing() and
// .followRedirects() port JMeter's own logic with that upstream fix applied, so this path
// is correct even against JMeter 5.6.3's own (buggy) inherited implementation.
buildStartedServer();
sampler.setMethod(HTTPConstants.POST);
sampler.setFollowRedirects(true);
sampler.setAutoRedirects(false);
sampler.addArgument("test1", TEST_ARGUMENT_1);
sampler.addArgument("test2", TEST_ARGUMENT_2);

HTTPSampleResult result = sample(SERVER_PATH_307_TO_ECHO, HTTPConstants.POST);

softly.assertThat(result.isSuccessful()).isTrue();
softly.assertThat(result.getResponseCode()).isEqualTo("200");
HTTPSampleResult followUp =
(HTTPSampleResult) result.getSubResults()[result.getSubResults().length - 1];
softly.assertThat(followUp.getHTTPMethod()).isEqualTo(HTTPConstants.POST);
softly.assertThat(followUp.getResponseDataAsString())
.isEqualTo("test1=" + TEST_ARGUMENT_1 + "&test2=" + TEST_ARGUMENT_2);
}

@Test
public void shouldPreserveRequestBodyWhenPostRedirectIsFollowedAs308ViaFollowRedirectsOnly()
throws Exception {
// Same fix as the 307 case above (RFC 9110 section 15.4.9): unlike 307, JMeter 5.6.3's
// isRedirect() already recognized 308 for any method, but computeMethodForRedirect() still
// rewrote it to GET, dropping the body.
buildStartedServer();
sampler.setMethod(HTTPConstants.POST);
sampler.setFollowRedirects(true);
sampler.setAutoRedirects(false);
sampler.addArgument("test1", TEST_ARGUMENT_1);
sampler.addArgument("test2", TEST_ARGUMENT_2);

HTTPSampleResult result = sample(SERVER_PATH_308_TO_ECHO, HTTPConstants.POST);

softly.assertThat(result.isSuccessful()).isTrue();
softly.assertThat(result.getResponseCode()).isEqualTo("200");
HTTPSampleResult followUp =
(HTTPSampleResult) result.getSubResults()[result.getSubResults().length - 1];
softly.assertThat(followUp.getHTTPMethod()).isEqualTo(HTTPConstants.POST);
softly.assertThat(followUp.getResponseDataAsString())
.isEqualTo("test1=" + TEST_ARGUMENT_1 + "&test2=" + TEST_ARGUMENT_2);
}

@Test
public void shouldMatchJMeter563OriginalBehaviorWhenLegacyRedirectMethodHandlingEnabled()
throws Exception {
// Opt-out flag for users who need byte-for-byte parity with stock JMeter 5.6.3, bug
// included: a POST+307 is returned as-is (never followed), matching HTTPSampleResult
// .isRedirect()'s unfixed behavior for that status code.
String property = "blazemeter.http.legacyRedirectMethodHandling";
String previous = JMeterUtils.getProperty(property);
JMeterUtils.setProperty(property, "true");
try {
buildStartedServer();
sampler.setMethod(HTTPConstants.POST);
sampler.setFollowRedirects(true);
sampler.setAutoRedirects(false);
sampler.addArgument("test1", TEST_ARGUMENT_1);
sampler.addArgument("test2", TEST_ARGUMENT_2);

HTTPSampleResult result = sample(SERVER_PATH_307_TO_ECHO, HTTPConstants.POST);

softly.assertThat(result.getResponseCode()).isEqualTo("307");
softly.assertThat(result.getSubResults().length).isEqualTo(0);
} finally {
if (previous == null) {
JMeterUtils.getJMeterProperties().remove(property);
} else {
JMeterUtils.setProperty(property, previous);
}
}
}

private void buildStartedServer() throws Exception {
server = new ServerBuilder()
.withHTTP2()
Expand Down
22 changes: 22 additions & 0 deletions src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ public class ServerBuilder {
*/
public static final String SERVER_PATH_401_NO_WWW_AUTHENTICATE = "/test/401-no-www-authenticate";
public static final String SERVER_PATH_302 = "/test/302";
/** Redirects to {@link #SERVER_PATH_200_WITH_BODY}, which echoes back whatever body it received. */
public static final String SERVER_PATH_302_TO_ECHO = "/test/302-to-echo";
/** Same as {@link #SERVER_PATH_302_TO_ECHO} but with a 307, which RFC 9110 requires to
* preserve the original method and body (unlike 301/302/303). */
public static final String SERVER_PATH_307_TO_ECHO = "/test/307-to-echo";
/** Same as {@link #SERVER_PATH_307_TO_ECHO} but with a 308 (also method/body-preserving). */
public static final String SERVER_PATH_308_TO_ECHO = "/test/308-to-echo";
public static final String SERVER_PATH_200_WITH_BODY = "/test/body";
public static final String SERVER_PATH_JSON_ONLY = "/test/json-only";
public static final String SERVER_PATH_DELETE_DATA = "/test/delete";
Expand Down Expand Up @@ -325,6 +332,21 @@ protected void service(HttpServletRequest req, HttpServletResponse resp)
"https://localhost:" + req.getLocalPort() + SERVER_PATH_200);
resp.setStatus(HttpStatus.FOUND_302);
break;
case SERVER_PATH_302_TO_ECHO:
resp.addHeader(HTTPConstants.HEADER_LOCATION,
"https://localhost:" + req.getLocalPort() + SERVER_PATH_200_WITH_BODY);
resp.setStatus(HttpStatus.FOUND_302);
break;
case SERVER_PATH_307_TO_ECHO:
resp.addHeader(HTTPConstants.HEADER_LOCATION,
"https://localhost:" + req.getLocalPort() + SERVER_PATH_200_WITH_BODY);
resp.setStatus(HttpStatus.TEMPORARY_REDIRECT_307);
break;
case SERVER_PATH_308_TO_ECHO:
resp.addHeader(HTTPConstants.HEADER_LOCATION,
"https://localhost:" + req.getLocalPort() + SERVER_PATH_200_WITH_BODY);
resp.setStatus(HttpStatus.PERMANENT_REDIRECT_308);
break;
case SERVER_PATH_200_WITH_BODY:
String bodyRequest = req.getReader().lines().collect(Collectors.joining());
resp.getWriter().write(bodyRequest);
Expand Down
Loading