From 3850a42448c506a6ddb5154b7159b862e34014ca Mon Sep 17 00:00:00 2001 From: Arturo Bernal Date: Mon, 16 Feb 2026 17:22:23 +0100 Subject: [PATCH] HTTP/2: validate trailer header fields Reject pseudo-headers in inbound and outbound trailers --- .../impl/nio/AbstractH2StreamMultiplexer.java | 1 + .../http2/impl/nio/ClientH2StreamHandler.java | 1 + .../impl/nio/TrailersValidationSupport.java | 56 +++++++++++++++++++ .../nio/TestAbstractH2StreamMultiplexer.java | 21 +++++++ .../impl/nio/TestClientH2StreamHandler.java | 28 ++++++++++ .../nio/TrailersValidationSupportTest.java | 52 +++++++++++++++++ 6 files changed, 159 insertions(+) create mode 100644 httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/TrailersValidationSupport.java create mode 100644 httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TrailersValidationSupportTest.java diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java index 89f3ccd6e1..c71f2fb020 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/AbstractH2StreamMultiplexer.java @@ -1555,6 +1555,7 @@ public void endStream(final List trailers) throws IOException ensureNotClosed(); localClosed = true; if (trailers != null && !trailers.isEmpty()) { + TrailersValidationSupport.verify(trailers); commitHeaders(id, trailers, true); } else { final RawFrame frame = frameFactory.createData(id, null, true); diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamHandler.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamHandler.java index 80e6db1d77..ea87e03db4 100644 --- a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamHandler.java +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/ClientH2StreamHandler.java @@ -216,6 +216,7 @@ public void consumeHeader(final List
headers, final boolean endStream) t responseState.set(endStream ? MessageState.COMPLETE : MessageState.BODY); break; case BODY: + TrailersValidationSupport.verify(headers); responseState.set(MessageState.COMPLETE); exchangeHandler.streamEnd(headers); break; diff --git a/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/TrailersValidationSupport.java b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/TrailersValidationSupport.java new file mode 100644 index 0000000000..cf8cbc277d --- /dev/null +++ b/httpcore5-h2/src/main/java/org/apache/hc/core5/http2/impl/nio/TrailersValidationSupport.java @@ -0,0 +1,56 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.impl.nio; + +import java.util.List; + +import org.apache.hc.core5.annotation.Internal; +import org.apache.hc.core5.http.Header; +import org.apache.hc.core5.http2.H2ConnectionException; +import org.apache.hc.core5.http2.H2Error; + + +@Internal +final class TrailersValidationSupport { + + static void verify(final List trailers) throws H2ConnectionException { + if (trailers == null || trailers.isEmpty()) { + return; + } + for (int i = 0; i < trailers.size(); i++) { + final String name = trailers.get(i).getName(); + if (name != null && !name.isEmpty() && name.charAt(0) == ':') { + throw new H2ConnectionException(H2Error.PROTOCOL_ERROR, "Pseudo-header '" + name + "' is not allowed in trailers"); + } + } + } + + + private TrailersValidationSupport() { + } + +} diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java index 4705e721ef..67427bf2a2 100644 --- a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestAbstractH2StreamMultiplexer.java @@ -1078,6 +1078,27 @@ void testStreamIdleTimeoutTriggersH2StreamTimeoutException() throws Exception { Assertions.assertEquals(1, timeoutEx.getStreamId()); } + + @Test + void testOutboundTrailersWithPseudoHeaderRejected() throws Exception { + final AbstractH2StreamMultiplexer mux = new H2StreamMultiplexerImpl( + protocolIOSession, + FRAME_FACTORY, + StreamIdGenerator.EVEN, + httpProcessor, + CharCodingConfig.DEFAULT, + H2Config.custom().build(), + h2StreamListener, + () -> streamHandler); + + final H2StreamChannel channel = mux.createChannel(1); + mux.createStream(channel, streamHandler); + + final List
trailers = Arrays.asList( + new BasicHeader(":status", "200")); + + Assertions.assertThrows(H2ConnectionException.class, () -> channel.endStream(trailers)); + } private static byte[] encodeFrame(final RawFrame frame) throws IOException { final WritableByteChannelMock writableChannel = new WritableByteChannelMock(256); final FrameOutputBuffer outBuffer = new FrameOutputBuffer(16 * 1024); diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestClientH2StreamHandler.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestClientH2StreamHandler.java index c9c27c074d..f189f13819 100644 --- a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestClientH2StreamHandler.java +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TestClientH2StreamHandler.java @@ -27,15 +27,20 @@ package org.apache.hc.core5.http2.impl.nio; import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.List; +import org.apache.hc.core5.http.Header; import org.apache.hc.core5.http.ProtocolException; import org.apache.hc.core5.http.impl.BasicHttpConnectionMetrics; import org.apache.hc.core5.http.impl.BasicHttpTransportMetrics; +import org.apache.hc.core5.http.message.BasicHeader; import org.apache.hc.core5.http.nio.AsyncClientExchangeHandler; import org.apache.hc.core5.http.nio.AsyncPushConsumer; import org.apache.hc.core5.http.nio.HandlerFactory; import org.apache.hc.core5.http.protocol.HttpCoreContext; import org.apache.hc.core5.http.protocol.HttpProcessor; +import org.apache.hc.core5.http2.H2ConnectionException; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -101,4 +106,27 @@ void toStringIncludesStates() { Assertions.assertTrue(text.contains("responseState")); } + @Test + void consumeTrailersWithPseudoHeaderRejected() throws Exception { + final H2StreamChannel channel = Mockito.mock(H2StreamChannel.class); + final HttpProcessor httpProcessor = Mockito.mock(HttpProcessor.class); + final BasicHttpConnectionMetrics metrics = new BasicHttpConnectionMetrics( + new BasicHttpTransportMetrics(), new BasicHttpTransportMetrics()); + final AsyncClientExchangeHandler exchangeHandler = Mockito.mock(AsyncClientExchangeHandler.class); + @SuppressWarnings("unchecked") final HandlerFactory pushHandlerFactory = + (HandlerFactory) Mockito.mock(HandlerFactory.class); + final ClientH2StreamHandler handler = new ClientH2StreamHandler( + channel, httpProcessor, metrics, exchangeHandler, pushHandlerFactory, HttpCoreContext.create()); + + final List
responseHeaders = Collections.singletonList( + new BasicHeader(":status", "200")); + handler.consumeHeader(responseHeaders, false); + + final List
trailers = Collections.singletonList( + new BasicHeader(":status", "200")); + + Assertions.assertThrows(H2ConnectionException.class, () -> handler.consumeHeader(trailers, true)); + Mockito.verify(exchangeHandler, Mockito.never()).streamEnd(Mockito.anyList()); + } + } diff --git a/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TrailersValidationSupportTest.java b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TrailersValidationSupportTest.java new file mode 100644 index 0000000000..1c4381c806 --- /dev/null +++ b/httpcore5-h2/src/test/java/org/apache/hc/core5/http2/impl/nio/TrailersValidationSupportTest.java @@ -0,0 +1,52 @@ +/* + * ==================================================================== + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + * ==================================================================== + * + * This software consists of voluntary contributions made by many + * individuals on behalf of the Apache Software Foundation. For more + * information on the Apache Software Foundation, please see + * . + * + */ +package org.apache.hc.core5.http2.impl.nio; + +import java.util.Arrays; +import java.util.Collections; + +import org.apache.hc.core5.http.message.BasicHeader; +import org.apache.hc.core5.http2.H2ConnectionException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class TestTrailersValidationSupport { + + @Test + void testVerifyInboundAcceptsRegularTrailers() throws Exception { + TrailersValidationSupport.verify(Arrays.asList( + new BasicHeader("checksum", "abc"), + new BasicHeader("x-foo", "bar"))); + } + + @Test + void testVerifyInboundRejectsPseudoHeaders() { + Assertions.assertThrows(H2ConnectionException.class, () -> + TrailersValidationSupport.verify(Collections.singletonList( + new BasicHeader(":status", "200")))); + } +}