From bdbc0aacedd9554c7b4d6218dde67db7b8a8c8b7 Mon Sep 17 00:00:00 2001 From: David <3dgiordano@gmail.com> Date: Sat, 18 Jul 2026 19:03:35 -0300 Subject: [PATCH] RFC 9110 Redirection and Legacy JMeter Redirect --- .../jmeter/http2/core/HTTP2JettyClient.java | 8 +- .../jmeter/http2/sampler/HTTP2Sampler.java | 111 +++++++++++++++ .../jmeter/http2/util/Rfc9110Redirects.java | 40 ++++++ .../http2/core/HTTP2JettyClientTest.java | 131 ++++++++++++++++++ .../jmeter/http2/core/ServerBuilder.java | 22 +++ 5 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/blazemeter/jmeter/http2/util/Rfc9110Redirects.java diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java index 007c301..0fafdb9 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -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; @@ -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)); } diff --git a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java index 7888aa9..b194155 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java +++ b/src/main/java/com/blazemeter/jmeter/http2/sampler/HTTP2Sampler.java @@ -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; @@ -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); diff --git a/src/main/java/com/blazemeter/jmeter/http2/util/Rfc9110Redirects.java b/src/main/java/com/blazemeter/jmeter/http2/util/Rfc9110Redirects.java new file mode 100644 index 0000000..a5411d7 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/util/Rfc9110Redirects.java @@ -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 + * apache/jmeter PR #6658 (merged + * 2026-03-19), but that fix is not in 5.6.3 or any released JMeter version yet. + * + *

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); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java index 6b515b9..647d97c 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClientTest.java @@ -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; @@ -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() diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java b/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java index 28cb501..8b9c0da 100644 --- a/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java +++ b/src/test/java/com/blazemeter/jmeter/http2/core/ServerBuilder.java @@ -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"; @@ -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);