diff --git a/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java b/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java index 8ad67f8f14e..dc2e771f16c 100644 --- a/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java +++ b/netty/src/main/java/io/grpc/netty/NettyChannelBuilder.java @@ -38,6 +38,7 @@ import io.grpc.HttpConnectProxiedSocketAddress; import io.grpc.Internal; import io.grpc.ManagedChannelBuilder; +import io.grpc.Metadata; import io.grpc.NameResolverProvider; import io.grpc.NameResolverRegistry; import io.grpc.internal.AtomicBackoff; @@ -60,12 +61,15 @@ import io.netty.channel.ReflectiveChannelFactory; import io.netty.channel.socket.nio.NioSocketChannel; import io.netty.handler.ssl.SslContext; +import io.netty.util.AsciiString; import java.net.InetSocketAddress; import java.net.SocketAddress; import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Set; import java.util.concurrent.Executor; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -105,6 +109,7 @@ public final class NettyChannelBuilder extends ForwardingChannelBuilder2 eventLoopGroupPool = DEFAULT_EVENT_LOOP_GROUP_POOL; private boolean autoFlowControl = DEFAULT_AUTO_FLOW_CONTROL; private int flowControlWindow = DEFAULT_FLOW_CONTROL_WINDOW; + private final Set neverIndexedMetadataKeys = new HashSet<>(); private int maxHeaderListSize = GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE; private int softLimitHeaderListSize = GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE; private int maxInboundMessageSize = GrpcUtil.DEFAULT_MAX_MESSAGE_SIZE; @@ -434,6 +439,35 @@ public NettyChannelBuilder flowControlWindow(int flowControlWindow) { return this; } + /** + * Configures an outbound metadata key to use HPACK's never-indexed literal representation. + * + *

All values associated with the key's normalized name will be sent as literals and will not + * be added to the peer's HPACK dynamic table. This method is additive and may be called multiple + * times. Configuring the same normalized key name more than once has no additional effect. By + * default, no metadata keys are configured as never indexed. + */ + @CanIgnoreReturnValue + public NettyChannelBuilder neverIndexMetadataKey(Metadata.Key key) { + neverIndexedMetadataKeys.add(AsciiString.of(checkNotNull(key, "key").name())); + return this; + } + + /** + * Configures outbound metadata keys to use HPACK's never-indexed literal representation. + * + *

This method is equivalent to calling {@link #neverIndexMetadataKey} for each key in {@code + * keys}. Duplicate normalized key names are ignored. + */ + @CanIgnoreReturnValue + public NettyChannelBuilder neverIndexMetadataKeys( + Collection> keys) { + for (Metadata.Key key : checkNotNull(keys, "keys")) { + neverIndexMetadataKey(key); + } + return this; + } + /** * Sets the maximum size of header list allowed to be received. This is cumulative size of the * headers with some overhead, as defined for @@ -626,6 +660,7 @@ ClientTransportFactory buildTransportFactory() { eventLoopGroupPool, autoFlowControl, flowControlWindow, + neverIndexedMetadataKeys, maxInboundMessageSize, maxHeaderListSize, softLimitHeaderListSize, @@ -769,6 +804,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto private final EventLoopGroup group; private final boolean autoFlowControl; private final int flowControlWindow; + private final Set neverIndexedMetadataKeys; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -790,6 +826,7 @@ private static final class NettyTransportFactory implements ClientTransportFacto ObjectPool groupPool, boolean autoFlowControl, int flowControlWindow, + Set neverIndexedMetadataKeys, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -807,6 +844,8 @@ private static final class NettyTransportFactory implements ClientTransportFacto this.group = groupPool.getObject(); this.autoFlowControl = autoFlowControl; this.flowControlWindow = flowControlWindow; + this.neverIndexedMetadataKeys = Collections.unmodifiableSet( + new HashSet<>(checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys"))); this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -856,6 +895,7 @@ public void run() { localNegotiator, autoFlowControl, flowControlWindow, + neverIndexedMetadataKeys, maxMessageSize, maxHeaderListSize, softLimitHeaderListSize, @@ -895,6 +935,7 @@ public SwapChannelCredentialsResult swapChannelCredentials(ChannelCredentials ch groupPool, autoFlowControl, flowControlWindow, + neverIndexedMetadataKeys, maxMessageSize, maxHeaderListSize, softLimitHeaderListSize, diff --git a/netty/src/main/java/io/grpc/netty/NettyClientHandler.java b/netty/src/main/java/io/grpc/netty/NettyClientHandler.java index 14a1d7535ad..4ccc8747bb5 100644 --- a/netty/src/main/java/io/grpc/netty/NettyClientHandler.java +++ b/netty/src/main/java/io/grpc/netty/NettyClientHandler.java @@ -76,6 +76,7 @@ import io.netty.handler.codec.http2.Http2Headers; import io.netty.handler.codec.http2.Http2HeadersDecoder; import io.netty.handler.codec.http2.Http2HeadersEncoder; +import io.netty.handler.codec.http2.Http2HeadersEncoder.SensitivityDetector; import io.netty.handler.codec.http2.Http2InboundFrameLogger; import io.netty.handler.codec.http2.Http2OutboundFrameLogger; import io.netty.handler.codec.http2.Http2Settings; @@ -84,12 +85,14 @@ import io.netty.handler.codec.http2.StreamBufferingEncoder; import io.netty.handler.codec.http2.UniformStreamByteDistributor; import io.netty.handler.logging.LogLevel; +import io.netty.util.AsciiString; import io.perfmark.PerfMark; import io.perfmark.Tag; import io.perfmark.TaskCloseable; import java.nio.channels.ClosedChannelException; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import java.util.concurrent.Executor; import java.util.logging.Level; import java.util.logging.Logger; @@ -158,6 +161,7 @@ static NettyClientHandler newHandler( @Nullable KeepAliveManager keepAliveManager, boolean autoFlowControl, int flowControlWindow, + Set neverIndexedMetadataKeys, int maxHeaderListSize, int softLimitHeaderListSize, Supplier stopwatchFactory, @@ -172,7 +176,7 @@ static NettyClientHandler newHandler( Http2HeadersDecoder headersDecoder = new GrpcHttp2ClientHeadersDecoder(maxHeaderListSize); Http2FrameReader frameReader = new DefaultHttp2FrameReader(headersDecoder); Http2HeadersEncoder encoder = new DefaultHttp2HeadersEncoder( - Http2HeadersEncoder.NEVER_SENSITIVE, false, 16, Integer.MAX_VALUE); + sensitivityDetector(neverIndexedMetadataKeys), false, 16, Integer.MAX_VALUE); Http2FrameWriter frameWriter = new DefaultHttp2FrameWriter(encoder); Http2Connection connection = new DefaultHttp2Connection(false); UniformStreamByteDistributor dist = new UniformStreamByteDistributor(connection); @@ -278,6 +282,20 @@ static NettyClientHandler newHandler( metricRecorder); } + @VisibleForTesting + static SensitivityDetector sensitivityDetector( + final Set neverIndexedMetadataKeys) { + if (neverIndexedMetadataKeys.isEmpty()) { + return Http2HeadersEncoder.NEVER_SENSITIVE; + } + return new SensitivityDetector() { + @Override + public boolean isSensitive(CharSequence name, CharSequence value) { + return neverIndexedMetadataKeys.contains(AsciiString.of(name)); + } + }; + } + private NettyClientHandler( Http2ConnectionDecoder decoder, Http2ConnectionEncoder encoder, diff --git a/netty/src/main/java/io/grpc/netty/NettyClientTransport.java b/netty/src/main/java/io/grpc/netty/NettyClientTransport.java index 6585df42df3..2d7873f0d7b 100644 --- a/netty/src/main/java/io/grpc/netty/NettyClientTransport.java +++ b/netty/src/main/java/io/grpc/netty/NettyClientTransport.java @@ -64,6 +64,7 @@ import java.net.SocketAddress; import java.nio.channels.ClosedChannelException; import java.util.Map; +import java.util.Set; import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import javax.annotation.Nullable; @@ -85,6 +86,7 @@ class NettyClientTransport implements ConnectionClientTransport, private final AsciiString userAgent; private final boolean autoFlowControl; private final int flowControlWindow; + private final Set neverIndexedMetadataKeys; private final int maxMessageSize; private final int maxHeaderListSize; private final int softLimitHeaderListSize; @@ -120,6 +122,7 @@ class NettyClientTransport implements ConnectionClientTransport, ProtocolNegotiator negotiator, boolean autoFlowControl, int flowControlWindow, + Set neverIndexedMetadataKeys, int maxMessageSize, int maxHeaderListSize, int softLimitHeaderListSize, @@ -145,6 +148,8 @@ class NettyClientTransport implements ConnectionClientTransport, this.channelOptions = Preconditions.checkNotNull(channelOptions, "channelOptions"); this.autoFlowControl = autoFlowControl; this.flowControlWindow = flowControlWindow; + this.neverIndexedMetadataKeys = + Preconditions.checkNotNull(neverIndexedMetadataKeys, "neverIndexedMetadataKeys"); this.maxMessageSize = maxMessageSize; this.maxHeaderListSize = maxHeaderListSize; this.softLimitHeaderListSize = softLimitHeaderListSize; @@ -247,6 +252,7 @@ public Runnable start(Listener transportListener) { keepAliveManager, autoFlowControl, flowControlWindow, + neverIndexedMetadataKeys, maxHeaderListSize, softLimitHeaderListSize, GrpcUtil.STOPWATCH_SUPPLIER, diff --git a/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java b/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java index 95d54d13b82..1214c2a6f42 100644 --- a/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyChannelBuilderTest.java @@ -26,6 +26,7 @@ import io.grpc.ChannelCredentials; import io.grpc.InsecureChannelCredentials; import io.grpc.ManagedChannel; +import io.grpc.Metadata; import io.grpc.internal.ClientTransportFactory; import io.grpc.internal.ClientTransportFactory.SwapChannelCredentialsResult; import io.grpc.netty.NettyTestUtil.TrackingObjectPoolForTest; @@ -38,6 +39,8 @@ import io.netty.handler.ssl.SslContext; import java.net.InetSocketAddress; import java.net.SocketAddress; +import java.util.Arrays; +import java.util.Collections; import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLException; import org.junit.Test; @@ -49,6 +52,66 @@ public class NettyChannelBuilderTest { private final SslContext noSslContext = null; + @Test + public void neverIndexMetadataKeyIsFluentAndAdditive() { + NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo"); + Metadata.Key key = + Metadata.Key.of("X-High-Cardinality", Metadata.ASCII_STRING_MARSHALLER); + Metadata.Key sameNormalizedKey = + Metadata.Key.of("x-high-cardinality", Metadata.ASCII_STRING_MARSHALLER); + + assertThat(builder.neverIndexMetadataKey(key)).isSameInstanceAs(builder); + assertThat(builder.neverIndexMetadataKey(sameNormalizedKey)).isSameInstanceAs(builder); + } + + @Test + public void neverIndexMetadataKeyRejectsNull() { + NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo"); + + NullPointerException exception = + assertThrows(NullPointerException.class, () -> builder.neverIndexMetadataKey(null)); + + assertThat(exception).hasMessageThat().isEqualTo("key"); + } + + @Test + public void neverIndexMetadataKeysIsFluentAdditiveAndIgnoresDuplicates() { + NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo"); + Metadata.Key stringKey = + Metadata.Key.of("x-high-cardinality", Metadata.ASCII_STRING_MARSHALLER); + Metadata.Key duplicateStringKey = + Metadata.Key.of("X-High-Cardinality", Metadata.ASCII_STRING_MARSHALLER); + Metadata.Key binaryKey = + Metadata.Key.of("trace-bin", Metadata.BINARY_BYTE_MARSHALLER); + + assertThat(builder.neverIndexMetadataKey(stringKey)).isSameInstanceAs(builder); + assertThat(builder.neverIndexMetadataKeys( + Arrays.asList(stringKey, duplicateStringKey, binaryKey))) + .isSameInstanceAs(builder); + } + + @Test + public void neverIndexMetadataKeysRejectsNullCollection() { + NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo"); + + NullPointerException exception = + assertThrows(NullPointerException.class, () -> builder.neverIndexMetadataKeys(null)); + + assertThat(exception).hasMessageThat().isEqualTo("keys"); + } + + @Test + public void neverIndexMetadataKeysRejectsNullElement() { + NettyChannelBuilder builder = NettyChannelBuilder.forTarget("foo"); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> builder.neverIndexMetadataKeys( + Collections.>singletonList(null))); + + assertThat(exception).hasMessageThat().isEqualTo("key"); + } + private void shutdown(ManagedChannel mc) throws Exception { mc.shutdownNow(); assertTrue(mc.awaitTermination(1, TimeUnit.SECONDS)); diff --git a/netty/src/test/java/io/grpc/netty/NettyClientHandlerSensitivityDetectorTest.java b/netty/src/test/java/io/grpc/netty/NettyClientHandlerSensitivityDetectorTest.java new file mode 100644 index 00000000000..96561fef3e0 --- /dev/null +++ b/netty/src/test/java/io/grpc/netty/NettyClientHandlerSensitivityDetectorTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.netty; + +import static com.google.common.truth.Truth.assertThat; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.http2.DefaultHttp2Headers; +import io.netty.handler.codec.http2.DefaultHttp2HeadersDecoder; +import io.netty.handler.codec.http2.DefaultHttp2HeadersEncoder; +import io.netty.handler.codec.http2.Http2Headers; +import io.netty.handler.codec.http2.Http2HeadersEncoder; +import io.netty.util.AsciiString; +import java.util.Collections; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class NettyClientHandlerSensitivityDetectorTest { + private static final AsciiString CUSTOM_NAME = AsciiString.cached("custom-key"); + private static final AsciiString CUSTOM_VALUE = AsciiString.cached("custom-value"); + + @Test + public void emptyConfigurationUsesDefaultPolicy() { + assertThat(NettyClientHandler.sensitivityDetector(Collections.emptySet())) + .isSameInstanceAs(Http2HeadersEncoder.NEVER_SENSITIVE); + } + + @Test + public void configuredHeaderIsNeverIndexed() throws Exception { + DefaultHttp2HeadersEncoder encoder = new DefaultHttp2HeadersEncoder( + NettyClientHandler.sensitivityDetector(Collections.singleton(CUSTOM_NAME)), + false, + 16, + Integer.MAX_VALUE); + DefaultHttp2HeadersDecoder decoder = new DefaultHttp2HeadersDecoder(); + ByteBuf first = Unpooled.buffer(); + ByteBuf second = Unpooled.buffer(); + try { + Http2Headers headers = new DefaultHttp2Headers().add(CUSTOM_NAME, CUSTOM_VALUE); + + encoder.encodeHeaders(1, headers, first); + encoder.encodeHeaders(3, headers, second); + + assertThat(first.getUnsignedByte(first.readerIndex()) & 0xF0).isEqualTo(0x10); + assertThat(second.getUnsignedByte(second.readerIndex()) & 0xF0).isEqualTo(0x10); + assertThat(decoder.decodeHeaders(1, first).get(CUSTOM_NAME).toString()) + .isEqualTo(CUSTOM_VALUE.toString()); + assertThat(decoder.decodeHeaders(3, second).get(CUSTOM_NAME).toString()) + .isEqualTo(CUSTOM_VALUE.toString()); + } finally { + first.release(); + second.release(); + encoder.close(); + } + } +} diff --git a/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java b/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java index ef8d2e5efda..ff2598fb5bf 100644 --- a/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java +++ b/netty/src/test/java/io/grpc/netty/NettyClientTransportTest.java @@ -237,6 +237,7 @@ public void setSoLingerChannelOption() throws IOException, GeneralSecurityExcept newNegotiator(), false, DEFAULT_WINDOW_SIZE, + Collections.emptySet(), DEFAULT_MAX_MESSAGE_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, @@ -513,6 +514,7 @@ public void failingToConstructChannelShouldFailGracefully() throws Exception { newNegotiator(), false, DEFAULT_WINDOW_SIZE, + Collections.emptySet(), DEFAULT_MAX_MESSAGE_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, GrpcUtil.DEFAULT_MAX_HEADER_LIST_SIZE, @@ -1147,6 +1149,7 @@ private NettyClientTransport newTransport(ProtocolNegotiator negotiator, int max negotiator, false, DEFAULT_WINDOW_SIZE, + Collections.emptySet(), maxMsgSize, maxHeaderListSize, maxHeaderListSize, diff --git a/netty/src/test/java/io/grpc/netty/NeverIndexMetadataInteropTest.java b/netty/src/test/java/io/grpc/netty/NeverIndexMetadataInteropTest.java new file mode 100644 index 00000000000..c90ec216e9b --- /dev/null +++ b/netty/src/test/java/io/grpc/netty/NeverIndexMetadataInteropTest.java @@ -0,0 +1,122 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.grpc.netty; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.Server; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; +import io.grpc.stub.MetadataUtils; +import io.grpc.stub.StreamObserver; +import io.grpc.testing.protobuf.SimpleRequest; +import io.grpc.testing.protobuf.SimpleResponse; +import io.grpc.testing.protobuf.SimpleServiceGrpc; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class NeverIndexMetadataInteropTest { + private static final int RPC_COUNT = 10; + private static final Metadata.Key NEVER_INDEXED_REQUEST_METADATA_KEY = + Metadata.Key.of("x-hpack-never-indexed-request", Metadata.ASCII_STRING_MARSHALLER); + + private Server server; + private ManagedChannel channel; + + @After + public void tearDown() throws Exception { + if (channel != null) { + channel.shutdownNow(); + channel.awaitTermination(5, TimeUnit.SECONDS); + } + if (server != null) { + server.shutdownNow(); + server.awaitTermination(5, TimeUnit.SECONDS); + } + } + + @Test + public void neverIndexedRequestMetadataInteroperates() throws Exception { + AtomicReference requestMetadataCapture = new AtomicReference<>(); + server = NettyServerBuilder.forPort(0) + .addService( + ServerInterceptors.intercept( + new SimpleServiceImpl(), + new CapturingServerInterceptor(requestMetadataCapture))) + .build() + .start(); + + channel = NettyChannelBuilder.forAddress("localhost", server.getPort()) + .usePlaintext() + .neverIndexMetadataKey(NEVER_INDEXED_REQUEST_METADATA_KEY) + .build(); + + SimpleServiceGrpc.SimpleServiceBlockingStub baseStub = + SimpleServiceGrpc.newBlockingStub(channel); + for (int i = 0; i < RPC_COUNT; i++) { + String neverIndexedValue = "high-cardinality-value-" + i; + Metadata requestMetadata = new Metadata(); + requestMetadata.put(NEVER_INDEXED_REQUEST_METADATA_KEY, neverIndexedValue); + + SimpleResponse response = + baseStub.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(requestMetadata)) + .withDeadlineAfter(10, TimeUnit.SECONDS) + .unaryRpc(SimpleRequest.getDefaultInstance()); + + assertEquals(SimpleResponse.getDefaultInstance(), response); + assertNotNull(requestMetadataCapture.get()); + assertEquals( + neverIndexedValue, + requestMetadataCapture.get().get(NEVER_INDEXED_REQUEST_METADATA_KEY)); + } + } + + private static final class CapturingServerInterceptor implements ServerInterceptor { + private final AtomicReference requestMetadataCapture; + + CapturingServerInterceptor(AtomicReference requestMetadataCapture) { + this.requestMetadataCapture = requestMetadataCapture; + } + + @Override + public ServerCall.Listener interceptCall( + ServerCall call, + Metadata headers, + ServerCallHandler next) { + requestMetadataCapture.set(headers); + return next.startCall(call, headers); + } + } + + private static final class SimpleServiceImpl extends SimpleServiceGrpc.SimpleServiceImplBase { + @Override + public void unaryRpc(SimpleRequest request, StreamObserver responseObserver) { + responseObserver.onNext(SimpleResponse.getDefaultInstance()); + responseObserver.onCompleted(); + } + } +}