diff --git a/pom.xml b/pom.xml index 3d643fda..71087706 100644 --- a/pom.xml +++ b/pom.xml @@ -31,9 +31,9 @@ UTF-8 UTF-8 5.4.1 - 12.1.7 - - 1.20.0 + 12.1.11 + + 1.23.0 false 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 a06550f1..fd261847 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/HTTP2JettyClient.java @@ -4,6 +4,7 @@ import com.blazemeter.jmeter.http2.core.jetty.CustomWwwAuthenticationProtocolHandler; import com.blazemeter.jmeter.http2.core.jetty.custom.http2.CustomClientConnectionFactoryOverHTTP2; +import com.blazemeter.jmeter.http2.core.jetty.custom.http2.CustomHttpClientTransportOverHTTP2; import com.blazemeter.jmeter.http2.core.jetty.custom.http3.CustomClientConnectionFactoryOverHTTP3; import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; import com.blazemeter.jmeter.http2.util.BzmHttpPluginProperties; @@ -100,7 +101,6 @@ import org.eclipse.jetty.http2.HTTP2Session; import org.eclipse.jetty.http2.api.Session; import org.eclipse.jetty.http2.client.HTTP2Client; -import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2; import org.eclipse.jetty.http2.frames.Frame; import org.eclipse.jetty.http2.frames.GoAwayFrame; import org.eclipse.jetty.http2.frames.HeadersFrame; @@ -538,7 +538,7 @@ public HTTP2JettyClient(boolean http1UpgradeRequired, String name, } else { http2cClient.setMaxConcurrentPushedStreams(maxConcurrentPushedStreams); } - HttpClientTransport h2cTransport = new HttpClientTransportOverHTTP2(http2cClient); + HttpClientTransport h2cTransport = new CustomHttpClientTransportOverHTTP2(http2cClient); configureTransport(h2cTransport); this.httpClientH2cPrior = new HttpClient(h2cTransport); configureHttpClient(this.httpClientH2cPrior, h2cConnector); diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomClientConnectionFactoryOverHTTP2.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomClientConnectionFactoryOverHTTP2.java index b642ade8..3a7c0b11 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomClientConnectionFactoryOverHTTP2.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomClientConnectionFactoryOverHTTP2.java @@ -10,7 +10,7 @@ import org.eclipse.jetty.client.transport.HttpClientConnectionFactory; import org.eclipse.jetty.client.transport.HttpDestination; import org.eclipse.jetty.http2.client.HTTP2Client; -import org.eclipse.jetty.http2.client.HTTP2ClientConnectionFactory; +import org.eclipse.jetty.http2.client.transport.CustomHttp2ClientConfigurer; import org.eclipse.jetty.http2.client.transport.internal.HttpConnectionOverHTTP2; import org.eclipse.jetty.io.ClientConnectionFactory; import org.eclipse.jetty.io.EndPoint; @@ -22,7 +22,7 @@ */ public class CustomClientConnectionFactoryOverHTTP2 extends ContainerLifeCycle implements ClientConnectionFactory, HttpClient.Aware { - private final ClientConnectionFactory factory = new HTTP2ClientConnectionFactory(); + private final ClientConnectionFactory factory = new CustomHTTP2ClientConnectionFactory(); private final HTTP2Client http2Client; public CustomClientConnectionFactoryOverHTTP2(HTTP2Client http2Client) { @@ -32,15 +32,7 @@ public CustomClientConnectionFactoryOverHTTP2(HTTP2Client http2Client) { @Override public void setHttpClient(HttpClient httpClient) { - try { - java.lang.reflect.Method configureMethod = org.eclipse.jetty.http2.client.transport - .HttpClientTransportOverHTTP2.class.getDeclaredMethod( - "configure", HttpClient.class, HTTP2Client.class); - configureMethod.setAccessible(true); - configureMethod.invoke(null, httpClient, http2Client); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Unable to configure HTTP/2 transport", e); - } + CustomHttp2ClientConfigurer.configure(httpClient, http2Client); } @Override diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHTTP2ClientConnectionFactory.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHTTP2ClientConnectionFactory.java new file mode 100644 index 00000000..d8245afd --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHTTP2ClientConnectionFactory.java @@ -0,0 +1,164 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http2; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.eclipse.jetty.http2.FlowControlStrategy; +import org.eclipse.jetty.http2.HTTP2Connection; +import org.eclipse.jetty.http2.HTTP2Session; +import org.eclipse.jetty.http2.api.Session; +import org.eclipse.jetty.http2.client.CustomHttp2SessionContainerAccessor; +import org.eclipse.jetty.http2.client.HTTP2Client; +import org.eclipse.jetty.http2.client.internal.HTTP2ClientSession; +import org.eclipse.jetty.http2.frames.Frame; +import org.eclipse.jetty.http2.frames.PrefaceFrame; +import org.eclipse.jetty.http2.frames.SettingsFrame; +import org.eclipse.jetty.http2.frames.WindowUpdateFrame; +import org.eclipse.jetty.http2.generator.Generator; +import org.eclipse.jetty.http2.hpack.HpackContext; +import org.eclipse.jetty.io.ByteBufferPool; +import org.eclipse.jetty.io.ClientConnectionFactory; +import org.eclipse.jetty.io.Connection; +import org.eclipse.jetty.io.EndPoint; +import org.eclipse.jetty.util.Callback; +import org.eclipse.jetty.util.Promise; + +/** + * Jetty 12.1.11 {@code HTTP2ClientConnectionFactory} variant that installs + * {@link CustomParser} and {@link org.eclipse.jetty.http2.hpack.CustomHpackDecoder} + * for Firefox-inspired soft header handling. + */ +public class CustomHTTP2ClientConnectionFactory implements ClientConnectionFactory { + + @Override + public Connection newConnection(EndPoint endPoint, Map context) { + HTTP2Client client = (HTTP2Client) context.get(HTTP2Client.CONTEXT_KEY); + ByteBufferPool bufferPool = client.getByteBufferPool(); + Session.Listener listener = + (Session.Listener) context.get(HTTP2Client.SESSION_LISTENER_CONTEXT_KEY); + @SuppressWarnings("unchecked") + Promise sessionPromise = + (Promise) context.get(HTTP2Client.SESSION_PROMISE_CONTEXT_KEY); + + Generator generator = new Generator(bufferPool, client.isUseOutputDirectByteBuffers(), + client.getMaxHeaderBlockFragment()); + generator.getHpackEncoder().setMaxHeaderListSize(client.getMaxRequestHeadersSize()); + + FlowControlStrategy flowControl = client.getFlowControlStrategyFactory() + .newFlowControlStrategy(); + + CustomParser parser = new CustomParser(bufferPool, client.getMaxResponseHeadersSize()); + parser.setMaxSettingsKeys(client.getMaxSettingsKeys()); + + HTTP2ClientSession session = new HTTP2ClientSession(client.getScheduler(), endPoint, parser, + generator, listener, flowControl); + session.setMaxLocalStreams(client.getMaxLocalStreams()); + session.setMaxRemoteStreams(client.getMaxConcurrentPushedStreams()); + session.setMaxEncoderTableCapacity(client.getMaxEncoderTableCapacity()); + long streamIdleTimeout = client.getStreamIdleTimeout(); + if (streamIdleTimeout > 0) { + session.setStreamIdleTimeout(streamIdleTimeout); + } + + HTTP2ClientConnection connection = + new HTTP2ClientConnection(client, endPoint, session, sessionPromise, listener); + context.put(HTTP2Connection.class.getName(), connection); + connection.addEventListener(CustomHttp2SessionContainerAccessor.getSessionContainer(client)); + client.getEventListeners().forEach(session::addEventListener); + parser.init(connection); + + return customize(connection, context); + } + + private static class HTTP2ClientConnection extends HTTP2Connection implements Callback { + private final HTTP2Client client; + private final Promise promise; + private final Session.Listener listener; + + private HTTP2ClientConnection(HTTP2Client client, EndPoint endpoint, + HTTP2ClientSession session, Promise sessionPromise, + Session.Listener listener) { + super(client.getByteBufferPool(), client.getExecutor(), endpoint, session, + client.getInputBufferSize(), -1); + this.client = client; + this.promise = sessionPromise; + this.listener = listener; + setUseInputDirectByteBuffers(client.isUseInputDirectByteBuffers()); + setUseOutputDirectByteBuffers(client.isUseOutputDirectByteBuffers()); + } + + @Override + public void onOpen() { + HTTP2Session session = getSession(); + session.notifyLifeCycleOpen(); + + Map settings = listener.onPreface(session); + settings = settings == null ? new HashMap<>() : new HashMap<>(settings); + + settings.compute(SettingsFrame.HEADER_TABLE_SIZE, (k, v) -> { + if (v == null) { + v = client.getMaxDecoderTableCapacity(); + if (v == HpackContext.DEFAULT_MAX_TABLE_CAPACITY) { + v = null; + } + } + return v; + }); + settings.computeIfAbsent(SettingsFrame.MAX_CONCURRENT_STREAMS, + k -> client.getMaxConcurrentPushedStreams()); + settings.compute(SettingsFrame.INITIAL_WINDOW_SIZE, (k, v) -> { + if (v == null) { + v = client.getInitialStreamRecvWindow(); + if (v == FlowControlStrategy.DEFAULT_WINDOW_SIZE) { + v = null; + } + } + return v; + }); + settings.compute(SettingsFrame.MAX_FRAME_SIZE, (k, v) -> { + if (v == null) { + v = client.getMaxFrameSize(); + if (v == Frame.DEFAULT_MAX_SIZE) { + v = null; + } + } + return v; + }); + settings.compute(SettingsFrame.MAX_HEADER_LIST_SIZE, (k, v) -> { + if (v == null) { + v = client.getMaxResponseHeadersSize(); + if (v <= 0) { + v = null; + } + } + return v; + }); + + PrefaceFrame prefaceFrame = new PrefaceFrame(); + SettingsFrame settingsFrame = new SettingsFrame(settings, false); + + int windowDelta = + client.getInitialSessionRecvWindow() - FlowControlStrategy.DEFAULT_WINDOW_SIZE; + session.updateRecvWindow(windowDelta); + if (windowDelta > 0) { + session.frames(null, + List.of(prefaceFrame, settingsFrame, new WindowUpdateFrame(0, windowDelta)), this); + } else { + session.frames(null, List.of(prefaceFrame, settingsFrame), this); + } + } + + @Override + public void succeeded() { + super.onOpen(); + promise.succeeded(getSession()); + produce(); + } + + @Override + public void failed(Throwable ex) { + close(); + promise.failed(ex); + } + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizer.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizer.java new file mode 100644 index 00000000..cc8e58b7 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizer.java @@ -0,0 +1,118 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http2; + +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpTokens; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Firefox-inspired HTTP/2 header softening for load-testing clients. + * + *

Browsers (especially Chrome) reject non-numeric {@code :status} values. This helper is more + * permissive: it extracts a leading status code (Chrome HTTP/1 {@code ParseStatus} style) and + * trims soft-illegal field values so sampling can continue while logging the anomaly. + */ +public final class CustomHttp2HeaderNormalizer { + + private static final Logger LOG = LoggerFactory.getLogger(CustomHttp2HeaderNormalizer.class); + + private CustomHttp2HeaderNormalizer() { + } + + /** + * Normalizes a decoded header value before Jetty builds/emits the field. + * + * @return the value to use (possibly unchanged) + */ + public static String normalize(HttpHeader header, String name, String value) { + if (value == null) { + return null; + } + if (isStatusPseudoHeader(header, name)) { + return normalizeStatus(value); + } + return softenFieldValue(name, value); + } + + static boolean isStatusPseudoHeader(HttpHeader header, String name) { + if (header == HttpHeader.C_STATUS) { + return true; + } + return name != null && ":status".equalsIgnoreCase(name); + } + + /** + * Accepts {@code 299}, {@code 299 Akamai}, or leading spaces before digits. + * Returns the original value when no leading status code can be recovered. + */ + static String normalizeStatus(String value) { + String trimmedLeading = trimLeadingSpaces(value); + int digits = 0; + while (digits < trimmedLeading.length() + && trimmedLeading.charAt(digits) >= '0' + && trimmedLeading.charAt(digits) <= '9') { + digits++; + } + if (digits == 0) { + return value; + } + String code = trimmedLeading.substring(0, digits); + if (code.equals(value)) { + return value; + } + String reason = trimmedLeading.substring(digits).trim(); + if (reason.isEmpty() && code.equals(trimmedLeading)) { + if (!code.equals(value)) { + LOG.warn("Normalized HTTP/2 :status from [{}] to [{}]", value, code); + } + return code; + } + LOG.warn("Normalized HTTP/2 :status from [{}] to [{}] (ignored reason [{}])", + value, code, reason); + return code; + } + + /** + * Softens values that fail only because of leading/trailing SP/HTAB (RFC 9113). + * Control characters and other illegal octets are left untouched so Jetty can still reject them. + */ + static String softenFieldValue(String name, String value) { + if (HttpTokens.isLegalFieldValue(value)) { + return value; + } + String trimmed = trimHttpWhitespace(value); + if (trimmed.equals(value)) { + return value; + } + if (HttpTokens.isLegalFieldValue(trimmed)) { + LOG.warn("Trimmed illegal HTTP/2 header value for [{}]: [{}] -> [{}]", + name, value, trimmed); + return trimmed; + } + return value; + } + + private static String trimLeadingSpaces(String value) { + int i = 0; + while (i < value.length() && value.charAt(i) == ' ') { + i++; + } + return i == 0 ? value : value.substring(i); + } + + private static String trimHttpWhitespace(String value) { + int start = 0; + int end = value.length(); + while (start < end && isHttpWhitespace(value.charAt(start))) { + start++; + } + while (end > start && isHttpWhitespace(value.charAt(end - 1))) { + end--; + } + return (start == 0 && end == value.length()) ? value : value.substring(start, end); + } + + private static boolean isHttpWhitespace(char c) { + return c == ' ' || c == '\t'; + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttpClientTransportOverHTTP2.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttpClientTransportOverHTTP2.java new file mode 100644 index 00000000..e0112f13 --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttpClientTransportOverHTTP2.java @@ -0,0 +1,27 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http2; + +import java.io.IOException; +import java.util.Map; +import org.eclipse.jetty.http2.client.HTTP2Client; +import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2; +import org.eclipse.jetty.io.Connection; +import org.eclipse.jetty.io.EndPoint; + +/** + * Custom HTTP/2 client transport that uses {@link CustomHTTP2ClientConnectionFactory}. + */ +public class CustomHttpClientTransportOverHTTP2 extends HttpClientTransportOverHTTP2 { + + private final CustomHTTP2ClientConnectionFactory connectionFactory = + new CustomHTTP2ClientConnectionFactory(); + + public CustomHttpClientTransportOverHTTP2(HTTP2Client http2Client) { + super(http2Client); + } + + @Override + public Connection newConnection(EndPoint endPoint, Map context) + throws IOException { + return connectionFactory.newConnection(endPoint, context); + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomParser.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomParser.java new file mode 100644 index 00000000..add71eee --- /dev/null +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomParser.java @@ -0,0 +1,37 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http2; + +import java.lang.reflect.Field; +import org.eclipse.jetty.http2.hpack.CustomHpackDecoder; +import org.eclipse.jetty.http2.parser.Parser; +import org.eclipse.jetty.io.ByteBufferPool; +import org.eclipse.jetty.io.RateControl; + +/** + * Jetty HTTP/2 {@link Parser} that installs {@link CustomHpackDecoder} before {@link #init}. + * + *

{@code hpackDecoder} is {@code final} in Jetty; we replace it reflectively after + * {@code super(...)} and before {@code init(...)} so {@code HeaderBlockParser} captures the + * custom decoder. + */ +public class CustomParser extends Parser { + + public CustomParser(ByteBufferPool bufferPool, int maxHeaderSize) { + super(bufferPool, maxHeaderSize); + replaceHpackDecoder(maxHeaderSize); + } + + public CustomParser(ByteBufferPool bufferPool, int maxHeaderSize, RateControl rateControl) { + super(bufferPool, maxHeaderSize, rateControl); + replaceHpackDecoder(maxHeaderSize); + } + + private void replaceHpackDecoder(int maxHeaderSize) { + try { + Field field = Parser.class.getDeclaredField("hpackDecoder"); + field.setAccessible(true); + field.set(this, new CustomHpackDecoder(maxHeaderSize, this::getBeginNanoTime)); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to install CustomHpackDecoder into Parser", e); + } + } +} diff --git a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http3/CustomClientConnectionFactoryOverHTTP3.java b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http3/CustomClientConnectionFactoryOverHTTP3.java index 4ec94d25..d876ba6c 100644 --- a/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http3/CustomClientConnectionFactoryOverHTTP3.java +++ b/src/main/java/com/blazemeter/jmeter/http2/core/jetty/custom/http3/CustomClientConnectionFactoryOverHTTP3.java @@ -7,7 +7,7 @@ import org.eclipse.jetty.client.transport.HttpClientTransportDynamic; import org.eclipse.jetty.http3.client.HTTP3Client; import org.eclipse.jetty.http3.client.HTTP3ClientConnectionFactory; -import org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3; +import org.eclipse.jetty.http3.client.transport.CustomHttp3ClientConfigurer; import org.eclipse.jetty.io.ClientConnectionFactory; import org.eclipse.jetty.io.EndPoint; import org.eclipse.jetty.io.Transport; @@ -31,14 +31,7 @@ public CustomClientConnectionFactoryOverHTTP3(HTTP3Client http3Client) { @Override public void setHttpClient(HttpClient httpClient) { - try { - java.lang.reflect.Method configureMethod = HttpClientTransportOverHTTP3.class - .getDeclaredMethod("configure", HttpClient.class, HTTP3Client.class); - configureMethod.setAccessible(true); - configureMethod.invoke(null, httpClient, http3Client); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Unable to configure HTTP/3 transport", e); - } + CustomHttp3ClientConfigurer.configure(httpClient, http3Client); } @Override diff --git a/src/main/java/org/eclipse/jetty/http2/client/CustomHttp2SessionContainerAccessor.java b/src/main/java/org/eclipse/jetty/http2/client/CustomHttp2SessionContainerAccessor.java new file mode 100644 index 00000000..168e5624 --- /dev/null +++ b/src/main/java/org/eclipse/jetty/http2/client/CustomHttp2SessionContainerAccessor.java @@ -0,0 +1,27 @@ +package org.eclipse.jetty.http2.client; + +import org.eclipse.jetty.http2.SessionContainer; + +/** + * Deliberately placed in this Jetty package, not a {@code com.blazemeter} package: it exposes + * {@link HTTP2Client}'s package-private {@code getSessionContainer()} to our code without + * reflection. + * + *

{@code com.blazemeter...custom.http2.CustomHTTP2ClientConnectionFactory} rebuilds the + * connection setup that {@code HTTP2ClientConnectionFactory#newConnection} does upstream, + * including registering the client's session container as an event listener on the new + * connection. That container is only reachable via this package-private accessor, so this shim + * exposes it publicly instead of relying on reflection. + * + *

Keep in sync when upgrading Jetty: if {@code getSessionContainer} is ever made public or + * removed, this shim (and the caller) should be updated accordingly. + */ +public final class CustomHttp2SessionContainerAccessor { + + private CustomHttp2SessionContainerAccessor() { + } + + public static SessionContainer getSessionContainer(HTTP2Client client) { + return client.getSessionContainer(); + } +} diff --git a/src/main/java/org/eclipse/jetty/http2/client/transport/CustomHttp2ClientConfigurer.java b/src/main/java/org/eclipse/jetty/http2/client/transport/CustomHttp2ClientConfigurer.java new file mode 100644 index 00000000..88e975c1 --- /dev/null +++ b/src/main/java/org/eclipse/jetty/http2/client/transport/CustomHttp2ClientConfigurer.java @@ -0,0 +1,31 @@ +package org.eclipse.jetty.http2.client.transport; + +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.http2.client.HTTP2Client; + +/** + * Deliberately placed in this Jetty package, not a {@code com.blazemeter} package: it exposes + * {@link HttpClientTransportOverHTTP2}'s package-private {@code configure(HttpClient, HTTP2Client)} + * to our code without reflection. + * + *

{@code com.blazemeter...custom.http2.CustomClientConnectionFactoryOverHTTP2} is a + * {@code ClientConnectionFactory} plus {@code HttpClient.Aware} (used inside + * {@code HttpClientTransportDynamic}), not an {@code HttpClientTransport} subclass, so unlike + * {@code CustomHttpClientTransportOverHTTP2} it cannot inherit + * {@code HttpClientTransportOverHTTP2.doStart()} — which calls {@code configure} for free + * from within Jetty's own package. It must call {@code configure} itself from + * {@code setHttpClient(HttpClient)}, and since that method is package-private, this shim + * exposes it publicly instead of relying on reflection. + * + *

Keep in sync when upgrading Jetty: if {@code configure} is ever made public or removed, this + * shim (and the caller) should be updated accordingly. + */ +public final class CustomHttp2ClientConfigurer { + + private CustomHttp2ClientConfigurer() { + } + + public static void configure(HttpClient httpClient, HTTP2Client http2Client) { + HttpClientTransportOverHTTP2.configure(httpClient, http2Client); + } +} diff --git a/src/main/java/org/eclipse/jetty/http2/hpack/CustomHpackDecoder.java b/src/main/java/org/eclipse/jetty/http2/hpack/CustomHpackDecoder.java new file mode 100644 index 00000000..45d9aa82 --- /dev/null +++ b/src/main/java/org/eclipse/jetty/http2/hpack/CustomHpackDecoder.java @@ -0,0 +1,368 @@ +package org.eclipse.jetty.http2.hpack; + +import com.blazemeter.jmeter.http2.core.jetty.custom.http2.CustomHttp2HeaderNormalizer; +import java.nio.ByteBuffer; +import java.util.function.LongSupplier; +import org.eclipse.jetty.http.HttpField; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpTokens; +import org.eclipse.jetty.http.MetaData; +import org.eclipse.jetty.http.PreEncodedHttpField; +import org.eclipse.jetty.http.compression.EncodingException; +import org.eclipse.jetty.http.compression.HuffmanDecoder; +import org.eclipse.jetty.http.compression.NBitIntegerDecoder; +import org.eclipse.jetty.http2.hpack.HpackContext.Entry; +import org.eclipse.jetty.http2.hpack.internal.AuthorityHttpField; +import org.eclipse.jetty.http2.hpack.internal.MetaDataBuilder; +import org.eclipse.jetty.util.BufferUtil; +import org.eclipse.jetty.util.CharsetStringBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Jetty 12.1.11 {@link HpackDecoder} with Firefox-inspired soft normalization for load testing. + * + *

Keep in sync when upgrading Jetty; only the normalize hooks differ from upstream. + * + *

Deliberately placed in this Jetty package, not a {@code com.blazemeter} package: the + * {@link HpackDecoder#getHpackContext() context} it inherits is {@code private}, so this class + * must build its own {@link HpackContext}, and that constructor is package-private in Jetty + * (verified for both 12.1.7 and 12.1.11 via {@code javap}). Every other member this class uses + * from {@code HpackContext}, {@code MetaDataBuilder} and {@code HpackDecoder} is {@code public}; + * only the {@code HpackContext(int)} constructor forces the package match. + * + *

Keep in sync when upgrading Jetty: if {@code HpackContext(int)} is ever made public, this + * class could move to a {@code com.blazemeter} package instead of overriding every accessor. + * + *

This is not thread safe and may only be called by 1 thread at a time. + */ +public class CustomHpackDecoder extends HpackDecoder { + private static final Logger LOG = LoggerFactory.getLogger(CustomHpackDecoder.class); + private static final HttpField LOWER_CASE_CONTENT_LENGTH_0 = + new PreEncodedHttpField(HttpHeader.CONTENT_LENGTH, "content-length", "0"); + + private final HpackContext context; + private final MetaDataBuilder builder; + private final HuffmanDecoder huffmanDecoder; + private final NBitIntegerDecoder integerDecoder; + private final LongSupplier beginNanoTimeSupplier; + private int maxTableCapacity; + + /** + * @param maxHeaderSize the maximum allowed size of a decoded headers block, expressed as total + * of all name and value bytes, plus 32 bytes per field + * @param beginNanoTimeSupplier the supplier of a nano timestamp taken at the time the first byte + * was read + */ + public CustomHpackDecoder(int maxHeaderSize, LongSupplier beginNanoTimeSupplier) { + super(maxHeaderSize, beginNanoTimeSupplier); + this.beginNanoTimeSupplier = beginNanoTimeSupplier; + context = new HpackContext(HpackContext.DEFAULT_MAX_TABLE_CAPACITY); + builder = new MetaDataBuilder(maxHeaderSize); + huffmanDecoder = new HuffmanDecoder(); + integerDecoder = new NBitIntegerDecoder(); + setMaxTableCapacity(HpackContext.DEFAULT_MAX_TABLE_CAPACITY); + } + + @Override + public HpackContext getHpackContext() { + return context; + } + + @Override + public int getMaxTableCapacity() { + return maxTableCapacity; + } + + /** + * Sets the limit for the capacity of the dynamic header table. + * + *

This value acts as a limit for the values received from the remote peer via the HPACK + * dynamic table size update instruction. + * + *

After calling this method, a SETTINGS frame must be sent to the other peer, containing the + * {@code SETTINGS_HEADER_TABLE_SIZE} setting with the value passed as argument to this method. + * + * @param maxTableCapacity the limit for capacity of the dynamic header table + */ + @Override + public void setMaxTableCapacity(int maxTableCapacity) { + this.maxTableCapacity = maxTableCapacity; + } + + @Override + public int getMaxHeaderListSize() { + return builder.getMaxSize(); + } + + @Override + public void setMaxHeaderListSize(int maxHeaderListSize) { + builder.setMaxSize(maxHeaderListSize); + } + + @Override + public MetaData decode(ByteBuffer buffer) + throws HpackException.SessionException, HpackException.StreamException { + if (LOG.isDebugEnabled()) { + LOG.debug(String.format("CtxTbl[%x] decoding %d octets", context.hashCode(), + buffer.remaining())); + } + + // If the buffer is larger than the max headers size, don't even start decoding it. + int maxSize = builder.getMaxSize(); + if (maxSize > 0 && buffer.remaining() > maxSize) { + throw new HpackException.SessionException("Header fields size too large"); + } + + try { + boolean emitted = false; + while (buffer.hasRemaining()) { + if (LOG.isDebugEnabled()) { + LOG.debug("decode {}", BufferUtil.toHexString(buffer)); + } + + byte b = buffer.get(); + if (b < 0) { + // 7.1 indexed if the high bit is set + int index = integerDecode(buffer, 7); + Entry entry = context.get(index); + if (entry == null) { + throw new HpackException.SessionException("Unknown index %d", index); + } + + HttpField field = entry.getHttpField(); + if (entry.isStatic()) { + if (LOG.isDebugEnabled()) { + LOG.debug("decode IdxStatic {}", entry); + } + emitted = true; + builder.emit(field); + } else { + if (LOG.isDebugEnabled()) { + LOG.debug("decode Idx {}", entry); + } + + String name = field.getName(); + if (!HttpTokens.isLegalH2H3FieldName(name)) { + builder.streamException("Illegal header name %s", name); + } + + String value = CustomHttp2HeaderNormalizer.normalize(field.getHeader(), + field.getName(), field.getValue()); + if (!value.equals(field.getValue())) { + field = new HttpField(field.getHeader(), field.getName(), value); + } + if (!HttpTokens.isLegalFieldValue(value)) { + builder.streamException("Illegal header value %s", value); + } + + emitted = true; + builder.emit(field); + } + } else { + // look at the first nibble in detail + byte f = (byte) ((b & 0xF0) >> 4); + String name; + HttpHeader header = null; + String value; + + boolean indexed; + int nameIndex; + + switch (f) { + case 2: // 7.3 + case 3: // 7.3 + // change table size + int size = integerDecode(buffer, 5); + if (LOG.isDebugEnabled()) { + LOG.debug("decode resize={}", size); + } + if (size > getMaxTableCapacity()) { + throw new HpackException.CompressionException( + "Dynamic table resize exceeded max limit"); + } + if (emitted) { + throw new HpackException.CompressionException( + "Dynamic table resize after fields"); + } + context.resize(size); + continue; + + case 0: // 7.2.2 + case 1: // 7.2.3 + indexed = false; + nameIndex = integerDecode(buffer, 4); + break; + + case 4: // 7.2.1 + case 5: // 7.2.1 + case 6: // 7.2.1 + case 7: // 7.2.1 + indexed = true; + nameIndex = integerDecode(buffer, 6); + break; + + default: + throw new IllegalStateException(); + } + + boolean huffmanName = false; + + // decode the name + if (nameIndex > 0) { + Entry nameEntry = context.get(nameIndex); + if (nameEntry == null) { + throw new HpackException.CompressionException("Unknown index %d", nameIndex); + } + name = nameEntry.getHttpField().getName(); + header = nameEntry.getHttpField().getHeader(); + } else { + huffmanName = (buffer.get() & 0x80) == 0x80; + int length = integerDecode(buffer, 7); + if (huffmanName) { + name = huffmanDecode(buffer, length); + } else { + name = toISO88591String(buffer, length); + } + } + + boolean illegalName = !HttpTokens.isLegalH2H3FieldName(name); + if (illegalName) { + builder.streamException("Illegal header name %s", name); + } else { + header = HttpHeader.CACHE.get(name); + } + + // decode the value + boolean huffmanValue = (buffer.get() & 0x80) == 0x80; + int length = integerDecode(buffer, 7); + if (huffmanValue) { + value = huffmanDecode(buffer, length); + } else { + value = toISO88591String(buffer, length); + } + + value = CustomHttp2HeaderNormalizer.normalize(header, name, value); + + boolean illegalValue = !HttpTokens.isLegalFieldValue(value); + if (illegalValue) { + builder.streamException("Illegal header value %s", value); + } + + HttpField httpField = processField(header, name, value, indexed); + emitted = true; + builder.emit(httpField); + + // If indexed add to dynamic table. + if (indexed) { + context.add(httpField); + } + + if (LOG.isDebugEnabled()) { + LOG.debug("decoded '{}' by {}/{}/{}", httpField, + nameIndex > 0 ? "IdxName" : (huffmanName ? "HuffName" : "LitName"), + huffmanValue ? "HuffVal" : "LitVal", indexed ? "Idx" : ""); + } + } + } + + builder.setBeginNanoTime(beginNanoTimeSupplier.getAsLong()); + return builder.build(); + } catch (HpackException ex) { + throw ex; + } catch (Throwable t) { + throw new HpackException.SessionException(t, "HPACK decoding failure: %s", t.getMessage()); + } + } + + private HttpField processField(HttpHeader header, String name, String value, boolean indexed) + throws HpackException.SessionException { + HttpField field; + if (header == null) { + // just make a normal field and bypass header name lookup + field = new HttpField(null, name, value); + } else { + try { + // might be worthwhile to create a value HttpField if it is indexed + // and/or of a type that may be looked up multiple times. + switch (header) { + case C_STATUS: + if (indexed) { + field = new HttpField.IntValueHttpField(header, name, value); + } else { + field = new HttpField(header, name, value); + } + break; + case C_AUTHORITY: + field = new AuthorityHttpField(value); + break; + case CONTENT_LENGTH: + if ("0".equals(value)) { + field = LOWER_CASE_CONTENT_LENGTH_0; + } else { + field = new HttpField.LongValueHttpField(header, name, value); + } + break; + default: + field = new HttpField(header, name, value); + break; + } + } catch (Throwable t) { + builder.streamException(t); + field = new HttpField(header, name, value); + } + } + + return field; + } + + private int integerDecode(ByteBuffer buffer, int prefix) + throws HpackException.CompressionException { + try { + if (prefix != 8) { + buffer.position(buffer.position() - 1); + } + + integerDecoder.setPrefix(prefix); + int decodedInt = integerDecoder.decodeInt(buffer); + if (decodedInt < 0) { + throw new EncodingException("invalid integer encoding"); + } + return decodedInt; + } catch (EncodingException e) { + throw new HpackException.CompressionException(e, e.getMessage()); + } finally { + integerDecoder.reset(); + } + } + + private String huffmanDecode(ByteBuffer buffer, int length) + throws HpackException.CompressionException { + try { + huffmanDecoder.setLength(length); + String decoded = huffmanDecoder.decode(buffer); + if (decoded == null) { + throw new HpackException.CompressionException("invalid string encoding"); + } + return decoded; + } catch (EncodingException e) { + throw new HpackException.CompressionException(e, e.getMessage()); + } finally { + huffmanDecoder.reset(); + } + } + + public static String toISO88591String(ByteBuffer buffer, int length) { + CharsetStringBuilder.Iso88591StringBuilder stringBuilder = + new CharsetStringBuilder.Iso88591StringBuilder(); + for (int i = 0; i < length; ++i) { + stringBuilder.append(buffer.get()); + } + return stringBuilder.build(); + } + + @Override + public String toString() { + return String.format("CustomHpackDecoder@%x{%s}", hashCode(), context); + } +} diff --git a/src/main/java/org/eclipse/jetty/http3/client/transport/CustomHttp3ClientConfigurer.java b/src/main/java/org/eclipse/jetty/http3/client/transport/CustomHttp3ClientConfigurer.java new file mode 100644 index 00000000..415cedc0 --- /dev/null +++ b/src/main/java/org/eclipse/jetty/http3/client/transport/CustomHttp3ClientConfigurer.java @@ -0,0 +1,31 @@ +package org.eclipse.jetty.http3.client.transport; + +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.http3.client.HTTP3Client; + +/** + * Deliberately placed in this Jetty package, not a {@code com.blazemeter} package: it exposes + * {@link HttpClientTransportOverHTTP3}'s package-private {@code configure(HttpClient, HTTP3Client)} + * to our code without reflection. + * + *

{@code com.blazemeter...custom.http3.CustomClientConnectionFactoryOverHTTP3} is a + * {@code ClientConnectionFactory} plus {@code HttpClient.Aware} (used inside + * {@code HttpClientTransportDynamic}), not an {@code HttpClientTransport} subclass, so it cannot + * inherit {@code HttpClientTransportOverHTTP3.doStart()} — which calls {@code configure} for free + * from within Jetty's own package. It must call {@code configure} itself from + * {@code setHttpClient(HttpClient)}, and since that method is package-private, this shim exposes + * it publicly instead of relying on reflection. Mirrors + * {@code org.eclipse.jetty.http2.client.transport.CustomHttp2ClientConfigurer} for the HTTP/2 case. + * + *

Keep in sync when upgrading Jetty: if {@code configure} is ever made public or removed, this + * shim (and the caller) should be updated accordingly. + */ +public final class CustomHttp3ClientConfigurer { + + private CustomHttp3ClientConfigurer() { + } + + public static void configure(HttpClient httpClient, HTTP3Client http3Client) { + HttpClientTransportOverHTTP3.configure(httpClient, http3Client); + } +} diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusDecoderTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusDecoderTest.java new file mode 100644 index 00000000..7a66aa60 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusDecoderTest.java @@ -0,0 +1,169 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowable; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import org.eclipse.jetty.http.HttpTokens; +import org.eclipse.jetty.http.MetaData; +import org.eclipse.jetty.http2.hpack.HpackDecoder; +import org.eclipse.jetty.http2.hpack.HpackException; +import org.eclipse.jetty.http2.hpack.CustomHpackDecoder; +import org.junit.Test; + +/** + * Reproduces Jetty HPACK failures seen with Akamai-style status values such as + * {@code :status: 299 Akamai}, and verifies the tolerant decoder accepts them. + * + *

HTTP/2 {@code :status} must be a 3-digit integer only (RFC 9113). Some intermediaries + * still emit an HTTP/1-style reason phrase in the pseudo-header. Stock Jetty fails while + * parsing that value as an int. {@link CustomHpackDecoder} normalizes leading digits + * (Firefox-inspired soft handling for load testing). + */ +public class HpackAkamaiStatusDecoderTest { + + private static final String AKAMAI_STATUS_WITH_REASON = "299 Akamai"; + + @Test + public void akamaiStatusWithReasonIsLegalFieldValue() { + assertThat(HttpTokens.isLegalFieldValue(AKAMAI_STATUS_WITH_REASON)).isTrue(); + assertThat(HttpTokens.isLegalFieldValue("299")).isTrue(); + } + + @Test + public void trailingSpaceMakesAkamaiStatusIllegalFieldValue() { + assertThat(HttpTokens.isLegalFieldValue(AKAMAI_STATUS_WITH_REASON + " ")).isFalse(); + assertThat(HttpTokens.isLegalFieldValue(" " + AKAMAI_STATUS_WITH_REASON)).isFalse(); + } + + @Test + public void stockDecoderFailsOnStatusPseudoHeaderWithAkamaiReasonPhrase() { + ByteBuffer block = literalStatusHeader(AKAMAI_STATUS_WITH_REASON, false); + HpackDecoder decoder = new HpackDecoder(16 * 1024, System::nanoTime); + + assertThatThrownBy(() -> decoder.decode(block)) + .isInstanceOf(HpackException.SessionException.class) + .hasMessageContaining("HPACK decoding failure"); + } + + @Test + public void stockDecoderFailsOnIndexedStatusPseudoHeaderWithAkamaiReasonPhrase() { + ByteBuffer block = literalStatusHeader(AKAMAI_STATUS_WITH_REASON, true); + HpackDecoder decoder = new HpackDecoder(16 * 1024, System::nanoTime); + + assertThatThrownBy(() -> decoder.decode(block)) + .isInstanceOf(HpackException.SessionException.class) + .hasMessageContaining("HPACK decoding failure"); + } + + @Test + public void tolerantDecoderAcceptsStatusPseudoHeaderWithAkamaiReasonPhrase() throws Exception { + ByteBuffer block = literalStatusHeader(AKAMAI_STATUS_WITH_REASON, false); + MetaData metadata = new CustomHpackDecoder(16 * 1024, System::nanoTime).decode(block); + + assertThat(metadata).isInstanceOf(MetaData.Response.class); + assertThat(((MetaData.Response) metadata).getStatus()).isEqualTo(299); + } + + @Test + public void tolerantDecoderAcceptsIndexedStatusPseudoHeaderWithAkamaiReasonPhrase() + throws Exception { + ByteBuffer block = literalStatusHeader(AKAMAI_STATUS_WITH_REASON, true); + MetaData metadata = new CustomHpackDecoder(16 * 1024, System::nanoTime).decode(block); + + assertThat(metadata).isInstanceOf(MetaData.Response.class); + assertThat(((MetaData.Response) metadata).getStatus()).isEqualTo(299); + } + + @Test + public void shouldDecodeNumericStatus299WithoutReason() throws Exception { + ByteBuffer block = literalStatusHeader("299", false); + MetaData metadata = new CustomHpackDecoder(16 * 1024, System::nanoTime).decode(block); + + assertThat(metadata).isInstanceOf(MetaData.Response.class); + assertThat(((MetaData.Response) metadata).getStatus()).isEqualTo(299); + } + + @Test + public void stockDecoderReportsIllegalHeaderValueWhenWarningHasTrailingSpace() { + ByteBuffer block = status200PlusWarning(AKAMAI_STATUS_WITH_REASON + " "); + HpackDecoder decoder = new HpackDecoder(16 * 1024, System::nanoTime); + + assertThatThrownBy(() -> decoder.decode(block)) + .isInstanceOf(HpackException.StreamException.class) + .hasMessageContaining("Illegal header value") + .hasMessageContaining(AKAMAI_STATUS_WITH_REASON); + } + + @Test + public void tolerantDecoderTrimsTrailingSpaceOnWarningHeader() throws Exception { + ByteBuffer block = status200PlusWarning(AKAMAI_STATUS_WITH_REASON + " "); + MetaData metadata = new CustomHpackDecoder(16 * 1024, System::nanoTime).decode(block); + + assertThat(metadata).isInstanceOf(MetaData.Response.class); + assertThat(((MetaData.Response) metadata).getStatus()).isEqualTo(200); + assertThat(metadata.getHttpFields().get("warning")).isEqualTo(AKAMAI_STATUS_WITH_REASON); + } + + @Test + public void realOversizedBlockSessionExceptionIsDetectedAsHpackFailure() { + ByteBuffer block = literalStatusHeader("200", false); + // Buffer is 5 bytes (0x08, len=3, '2','0','0'); cap below that to trip the + // whole-block guard at the top of CustomHpackDecoder.decode(). + CustomHpackDecoder decoder = new CustomHpackDecoder(4, System::nanoTime); + + Throwable thrown = catchThrowable(() -> decoder.decode(block)); + + assertThat(thrown) + .isInstanceOf(HpackException.SessionException.class) + .hasMessageContaining("Header fields size too large"); + assertThat(HpackFailureDetector.indicatesHpackFailure(thrown)) + .as("HpackFailureDetector must recognize CustomHpackDecoder's own SessionException") + .isTrue(); + } + + @Test + public void realOversizedFieldSessionExceptionIsDetectedAsHpackFailure() { + // Encoded block is only 12 bytes (0x08, len=10, 10 value bytes), but the decoded + // ":status" field accounts for 7 (name) + 10 (value) + 32 (overhead) = 49 logical bytes, + // so maxHeaderSize=20 clears the whole-block guard yet still trips MetaDataBuilder.emit(). + ByteBuffer block = literalStatusHeader("2000000000", false); + CustomHpackDecoder decoder = new CustomHpackDecoder(20, System::nanoTime); + + Throwable thrown = catchThrowable(() -> decoder.decode(block)); + + assertThat(thrown) + .isInstanceOf(HpackException.SessionException.class) + .hasMessageContaining("Header size") + .hasMessageContaining("49 > 20"); + assertThat(HpackFailureDetector.indicatesHpackFailure(thrown)) + .as("HpackFailureDetector must recognize CustomHpackDecoder's own SessionException") + .isTrue(); + } + + private static ByteBuffer literalStatusHeader(String value, boolean indexed) { + byte[] valueBytes = value.getBytes(StandardCharsets.ISO_8859_1); + ByteBuffer buffer = ByteBuffer.allocate(2 + valueBytes.length); + buffer.put(indexed ? (byte) 0x48 : (byte) 0x08); + buffer.put((byte) valueBytes.length); + buffer.put(valueBytes); + buffer.flip(); + return buffer; + } + + private static ByteBuffer status200PlusWarning(String warningValue) { + byte[] name = "warning".getBytes(StandardCharsets.ISO_8859_1); + byte[] value = warningValue.getBytes(StandardCharsets.ISO_8859_1); + ByteBuffer buffer = ByteBuffer.allocate(4 + name.length + value.length); + buffer.put((byte) 0x88); + buffer.put((byte) 0x00); + buffer.put((byte) name.length); + buffer.put(name); + buffer.put((byte) value.length); + buffer.put(value); + buffer.flip(); + return buffer; + } +} \ No newline at end of file diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusIntegrationTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusIntegrationTest.java new file mode 100644 index 00000000..2257e0c6 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/HpackAkamaiStatusIntegrationTest.java @@ -0,0 +1,225 @@ +package com.blazemeter.jmeter.http2.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.blazemeter.jmeter.http2.HTTP2TestBase; +import com.blazemeter.jmeter.http2.sampler.HTTP2Sampler; +import com.blazemeter.jmeter.http2.sampler.JMeterTestUtils; +import java.net.URI; +import java.net.URL; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.jmeter.protocol.http.sampler.HTTPSampleResult; +import org.apache.jmeter.protocol.http.util.HTTPConstants; +import org.apache.jmeter.util.JMeterUtils; +import org.eclipse.jetty.http2.HTTP2Session; +import org.eclipse.jetty.http2.api.Stream; +import org.eclipse.jetty.http2.api.server.ServerSessionListener; +import org.eclipse.jetty.http2.frames.FrameType; +import org.eclipse.jetty.http2.frames.HeadersFrame; +import org.eclipse.jetty.http2.server.RawHTTP2ServerConnectionFactory; +import org.eclipse.jetty.io.EndPoint; +import org.eclipse.jetty.server.HttpConfiguration; +import org.eclipse.jetty.server.Server; +import org.eclipse.jetty.server.ServerConnector; +import org.eclipse.jetty.util.Callback; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * End-to-end coverage of Akamai-style {@code :status: 299 Akamai} responses over HTTP/2. + * + *

Jetty's normal response encoder never puts a reason phrase into {@code :status}, so this + * test uses {@link RawHTTP2ServerConnectionFactory} and writes a raw HEADERS frame whose HPACK + * block contains the non-numeric status value reported in production. + * + *

Stock Jetty rejects that value; the plugin's tolerant HPACK path (Firefox-inspired soft + * normalization) accepts it and surfaces response code {@code 299}. + */ +public class HpackAkamaiStatusIntegrationTest extends HTTP2TestBase { + + private static final String AKAMAI_STATUS_WITH_REASON = "299 Akamai"; + private static final byte HEADERS_END_STREAM_END_HEADERS = 0x05; + + private String savedSharedPool; + private String savedProtocolErrorFallback; + private String savedPriorKnowledge; + private Server server; + private HTTP2JettyClient client; + private int serverPort = -1; + + @Before + public void setUp() throws Exception { + JMeterTestUtils.setupJmeterEnv(); + savedSharedPool = JMeterUtils.getProperty("httpJettyClient.sharedThreadPool"); + savedProtocolErrorFallback = + JMeterUtils.getProperty("httpJettyClient.protocolErrorFallbackEnabled"); + savedPriorKnowledge = JMeterUtils.getProperty("httpJettyClient.http2PriorKnowledge"); + JMeterUtils.setProperty("httpJettyClient.sharedThreadPool", "false"); + JMeterUtils.setProperty("httpJettyClient.protocolErrorFallbackEnabled", "false"); + JMeterUtils.setProperty("httpJettyClient.http2PriorKnowledge", "true"); + + server = startRawAkamaiStatusServer(); + serverPort = ((ServerConnector) server.getConnectors()[0]).getLocalPort(); + } + + @After + public void tearDown() throws Exception { + if (client != null) { + client.stop(); + client = null; + } + if (server != null) { + server.stop(); + server = null; + } + serverPort = -1; + restoreProperty("httpJettyClient.sharedThreadPool", savedSharedPool); + restoreProperty("httpJettyClient.protocolErrorFallbackEnabled", savedProtocolErrorFallback); + restoreProperty("httpJettyClient.http2PriorKnowledge", savedPriorKnowledge); + } + + @Test + public void shouldAcceptAkamaiStatusWithReasonPhraseThroughJettyClient() throws Exception { + client = new HTTP2JettyClient(false, "hpack-akamai-status-it", http2OnlyProfile()); + client.start(); + + HTTPSampleResult result = client.sample(buildSampler(), buildBaseResult(), false, 0); + + assertThat(result.isSuccessful()) + .as("Tolerant client must accept :status with an HTTP/1 reason phrase") + .isTrue(); + assertThat(result.getResponseCode()).isEqualTo("299"); + } + + private static Server startRawAkamaiStatusServer() throws Exception { + Server jetty = new Server(); + HttpConfiguration httpConfig = new HttpConfiguration(); + RawHTTP2ServerConnectionFactory http2 = + new RawHTTP2ServerConnectionFactory(httpConfig, new ServerSessionListener() { + @Override + public Stream.Listener onNewStream(Stream stream, HeadersFrame frame) { + writeRawStatusWithReason(stream); + return null; + } + }); + ServerConnector connector = new ServerConnector(jetty, http2); + connector.setPort(0); + jetty.addConnector(connector); + jetty.start(); + return jetty; + } + + /** + * Writes a HEADERS frame with HPACK {@code :status: 299 Akamai} directly to the connection, + * bypassing Jetty's encoder which would strip the reason phrase. + */ + private static void writeRawStatusWithReason(Stream stream) { + try { + HTTP2Session session = (HTTP2Session) stream.getSession(); + EndPoint endPoint = session.getEndPoint(); + ByteBuffer frame = buildHeadersFrame(stream.getId(), + literalStatusHpack(AKAMAI_STATUS_WITH_REASON)); + + CountDownLatch written = new CountDownLatch(1); + AtomicReference writeFailure = new AtomicReference<>(); + endPoint.write(new Callback() { + @Override + public void succeeded() { + written.countDown(); + } + + @Override + public void failed(Throwable x) { + writeFailure.set(x); + written.countDown(); + } + }, frame); + if (!written.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out writing raw HEADERS frame"); + } + if (writeFailure.get() != null) { + throw new IllegalStateException("Failed writing raw HEADERS frame", writeFailure.get()); + } + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private static ByteBuffer buildHeadersFrame(int streamId, byte[] hpack) { + ByteBuffer frame = ByteBuffer.allocate(9 + hpack.length); + int length = hpack.length; + frame.put((byte) ((length >>> 16) & 0xFF)); + frame.put((byte) ((length >>> 8) & 0xFF)); + frame.put((byte) (length & 0xFF)); + frame.put((byte) FrameType.HEADERS.getType()); + frame.put(HEADERS_END_STREAM_END_HEADERS); + frame.putInt(streamId); + frame.put(hpack); + frame.flip(); + return frame; + } + + private static byte[] literalStatusHpack(String value) { + byte[] valueBytes = value.getBytes(StandardCharsets.ISO_8859_1); + byte[] block = new byte[2 + valueBytes.length]; + block[0] = 0x08; // literal header field without indexing - name index 8 (:status) + block[1] = (byte) valueBytes.length; + System.arraycopy(valueBytes, 0, block, 2, valueBytes.length); + return block; + } + + private HTTP2Sampler buildSampler() { + HTTP2Sampler sampler = new HTTP2Sampler(); + sampler.setMethod(HTTPConstants.GET); + sampler.setDomain("localhost"); + sampler.setProtocol(HTTPConstants.PROTOCOL_HTTP); + sampler.setPort(serverPort); + sampler.setPath("/"); + sampler.setEnableHttp1(false); + sampler.setEnableHttp2(true); + sampler.setEnableHttp3(false); + sampler.setFallbackEnabled(false); + sampler.setProtocolErrorFallbackEnabled(false); + sampler.setHttp2PriorKnowledgeEnabled(true); + return sampler; + } + + private static HTTP2ClientProfileConfig http2OnlyProfile() { + return HTTP2ClientProfileConfig.builder() + .profile("browser-like") + .enableHttp3(false) + .enableHttp2(true) + .enableHttp1(false) + .alpnEnabled(false) + .fallbackEnabled(false) + .protocolErrorFallbackEnabled(false) + .altSvcCacheEnabled(false) + .http1OnlyCacheEnabled(false) + .http2PriorKnowledgeEnabled(true) + .build(); + } + + private HTTPSampleResult buildBaseResult() throws Exception { + HTTPSampleResult result = new HTTPSampleResult(); + URL url = new URI(HTTPConstants.PROTOCOL_HTTP, null, "localhost", serverPort, "/", null, null) + .toURL(); + result.setURL(url); + result.setHTTPMethod(HTTPConstants.GET); + return result; + } + + private static void restoreProperty(String key, String value) { + if (value == null) { + JMeterUtils.getJMeterProperties().remove(key); + } else { + JMeterUtils.setProperty(key, value); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizerTest.java b/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizerTest.java new file mode 100644 index 00000000..0cc95000 --- /dev/null +++ b/src/test/java/com/blazemeter/jmeter/http2/core/jetty/custom/http2/CustomHttp2HeaderNormalizerTest.java @@ -0,0 +1,33 @@ +package com.blazemeter.jmeter.http2.core.jetty.custom.http2; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.eclipse.jetty.http.HttpHeader; +import org.junit.Test; + +public class CustomHttp2HeaderNormalizerTest { + + @Test + public void normalizesStatusWithReasonPhrase() { + assertThat(CustomHttp2HeaderNormalizer.normalize( + HttpHeader.C_STATUS, ":status", "299 Akamai")).isEqualTo("299"); + } + + @Test + public void leavesNumericStatusUnchanged() { + assertThat(CustomHttp2HeaderNormalizer.normalize( + HttpHeader.C_STATUS, ":status", "299")).isEqualTo("299"); + } + + @Test + public void trimsSoftIllegalWhitespaceOnRegularHeaders() { + assertThat(CustomHttp2HeaderNormalizer.normalize( + null, "warning", "299 Akamai ")).isEqualTo("299 Akamai"); + } + + @Test + public void leavesHardIllegalValuesUntouched() { + assertThat(CustomHttp2HeaderNormalizer.normalize( + null, "x-test", "bad\nvalue")).isEqualTo("bad\nvalue"); + } +} \ No newline at end of file