From a91df58ca17d5b30c57c46dde5b1d60bb659b029 Mon Sep 17 00:00:00 2001
From: Scott Mitchell <scott_mitchell@apple.com>
Date: Tue, 11 Jul 2017 14:53:49 -0700
Subject: [PATCH] HTTP/2 enforce HTTP message flow

Motivation:
codec-http2 currently does not strictly enforce the HTTP/1.x semantics with respect to the number of headers defined in RFC 7540 Section 8.1 [1]. We currently don't validate the number of headers nor do we validate that the trailing headers should indicate EOS.

[1] https://tools.ietf.org/html/rfc7540#section-8.1

Modifications:
- DefaultHttp2ConnectionDecoder should only allow decoding of a single headers and a single trailers
- DefaultHttp2ConnectionEncoder should only allow encoding of a single headers and optionally a single trailers

Result:
Constraints of RFC 7540 restricting the number of headers/trailers is enforced.
---
 .../handler/codec/http/HttpStatusClass.java   |  21 ++
 .../codec/http2/DefaultHttp2Connection.java   |  52 +++--
 .../http2/DefaultHttp2ConnectionDecoder.java  |  11 +
 .../http2/DefaultHttp2ConnectionEncoder.java  |  18 +-
 .../handler/codec/http2/Http2Stream.java      |  32 ++-
 .../DefaultHttp2ConnectionDecoderTest.java    | 103 ++++++++-
 .../DefaultHttp2ConnectionEncoderTest.java    | 203 +++++++++++++++++-
 .../http2/InboundHttp2ToHttpAdapterTest.java  |  43 ----
 8 files changed, 419 insertions(+), 64 deletions(-)

diff --git a/codec-http/src/main/java/io/netty/handler/codec/http/HttpStatusClass.java b/codec-http/src/main/java/io/netty/handler/codec/http/HttpStatusClass.java
index 9f57e18984..0a4f4c11ab 100644
--- a/codec-http/src/main/java/io/netty/handler/codec/http/HttpStatusClass.java
+++ b/codec-http/src/main/java/io/netty/handler/codec/http/HttpStatusClass.java
@@ -74,6 +74,27 @@ public enum HttpStatusClass {
         return UNKNOWN;
     }
 
+    /**
+     * Returns the class of the specified HTTP status code.
+     * @param code Just the numeric portion of the http status code.
+     */
+    public static HttpStatusClass valueOf(CharSequence code) {
+        if (code != null && code.length() == 3) {
+            char c0 = code.charAt(0);
+            return isDigit(c0) && isDigit(code.charAt(1)) && isDigit(code.charAt(2)) ? valueOf(digit(c0) * 100)
+                                                                                     : UNKNOWN;
+        }
+        return UNKNOWN;
+    }
+
+    private static int digit(char c) {
+        return c - '0';
+    }
+
+    private static boolean isDigit(char c) {
+        return c >= '0' && c <= '9';
+    }
+
     private final int min;
     private final int max;
     private final AsciiString defaultReasonPhrase;
diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2Connection.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2Connection.java
index 2789423bc7..12815c225c 100644
--- a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2Connection.java
+++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2Connection.java
@@ -373,13 +373,16 @@ public class DefaultHttp2Connection implements Http2Connection {
      * Simple stream implementation. Streams can be compared to each other by priority.
      */
     private class DefaultStream implements Http2Stream {
-        private static final byte SENT_STATE_RST = 0x1;
-        private static final byte SENT_STATE_HEADERS = 0x2;
-        private static final byte SENT_STATE_PUSHPROMISE = 0x4;
+        private static final byte META_STATE_SENT_RST = 1;
+        private static final byte META_STATE_SENT_HEADERS = 1 << 1;
+        private static final byte META_STATE_SENT_TRAILERS = 1 << 2;
+        private static final byte META_STATE_SENT_PUSHPROMISE = 1 << 3;
+        private static final byte META_STATE_RECV_HEADERS = 1 << 4;
+        private static final byte META_STATE_RECV_TRAILERS = 1 << 5;
         private final int id;
         private final PropertyMap properties = new PropertyMap();
         private State state;
-        private byte sentState;
+        private byte metaState;
 
         DefaultStream(int id, State state) {
             this.id = id;
@@ -398,35 +401,60 @@ public class DefaultHttp2Connection implements Http2Connection {
 
         @Override
         public boolean isResetSent() {
-            return (sentState & SENT_STATE_RST) != 0;
+            return (metaState & META_STATE_SENT_RST) != 0;
         }
 
         @Override
         public Http2Stream resetSent() {
-            sentState |= SENT_STATE_RST;
+            metaState |= META_STATE_SENT_RST;
             return this;
         }
 
         @Override
-        public Http2Stream headersSent() {
-            sentState |= SENT_STATE_HEADERS;
+        public Http2Stream headersSent(boolean isInformational) {
+            if (!isInformational) {
+                metaState |= isHeadersSent() ? META_STATE_SENT_TRAILERS : META_STATE_SENT_HEADERS;
+            }
             return this;
         }
 
         @Override
         public boolean isHeadersSent() {
-            return (sentState & SENT_STATE_HEADERS) != 0;
+            return (metaState & META_STATE_SENT_HEADERS) != 0;
+        }
+
+        @Override
+        public boolean isTrailersSent() {
+            return (metaState & META_STATE_SENT_TRAILERS) != 0;
+        }
+
+        @Override
+        public Http2Stream headersReceived(boolean isInformational) {
+            if (!isInformational) {
+                metaState |= isHeadersReceived() ? META_STATE_RECV_TRAILERS : META_STATE_RECV_HEADERS;
+            }
+            return this;
+        }
+
+        @Override
+        public boolean isHeadersReceived() {
+            return (metaState & META_STATE_RECV_HEADERS) != 0;
+        }
+
+        @Override
+        public boolean isTrailersReceived() {
+            return (metaState & META_STATE_RECV_TRAILERS) != 0;
         }
 
         @Override
         public Http2Stream pushPromiseSent() {
-            sentState |= SENT_STATE_PUSHPROMISE;
+            metaState |= META_STATE_SENT_PUSHPROMISE;
             return this;
         }
 
         @Override
         public boolean isPushPromiseSent() {
-            return (sentState & SENT_STATE_PUSHPROMISE) != 0;
+            return (metaState & META_STATE_SENT_PUSHPROMISE) != 0;
         }
 
         @Override
@@ -599,7 +627,7 @@ public class DefaultHttp2Connection implements Http2Connection {
         }
 
         @Override
-        public Http2Stream headersSent() {
+        public Http2Stream headersSent(boolean isInformational) {
             throw new UnsupportedOperationException();
         }
 
diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoder.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoder.java
index ef643fafad..4027978651 100644
--- a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoder.java
+++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoder.java
@@ -16,6 +16,7 @@ package io.netty.handler.codec.http2;
 
 import io.netty.buffer.ByteBuf;
 import io.netty.channel.ChannelHandlerContext;
+import io.netty.handler.codec.http.HttpStatusClass;
 import io.netty.handler.codec.http2.Http2Connection.Endpoint;
 import io.netty.util.internal.UnstableApi;
 import io.netty.util.internal.logging.InternalLogger;
@@ -23,6 +24,7 @@ import io.netty.util.internal.logging.InternalLoggerFactory;
 
 import java.util.List;
 
+import static io.netty.handler.codec.http.HttpStatusClass.INFORMATIONAL;
 import static io.netty.handler.codec.http2.Http2CodecUtil.DEFAULT_PRIORITY_WEIGHT;
 import static io.netty.handler.codec.http2.Http2Error.INTERNAL_ERROR;
 import static io.netty.handler.codec.http2.Http2Error.PROTOCOL_ERROR;
@@ -282,6 +284,14 @@ public class DefaultHttp2ConnectionDecoder implements Http2ConnectionDecoder {
                 return;
             }
 
+            boolean isInformational = !connection.isServer() &&
+                    HttpStatusClass.valueOf(headers.status()) == INFORMATIONAL;
+            if ((isInformational || !endOfStream) && stream.isHeadersReceived() || stream.isTrailersReceived()) {
+                throw streamError(streamId, PROTOCOL_ERROR,
+                                  "Stream %d received too many headers EOS: %s state: %s",
+                                  streamId, endOfStream, stream.state());
+            }
+
             switch (stream.state()) {
                 case RESERVED_REMOTE:
                     stream.open(endOfStream);
@@ -305,6 +315,7 @@ public class DefaultHttp2ConnectionDecoder implements Http2ConnectionDecoder {
                             stream.state());
             }
 
+            stream.headersReceived(isInformational);
             encoder.flowController().updateDependencyTree(streamId, streamDependency, weight, exclusive);
 
             listener.onHeadersRead(ctx, streamId, headers, streamDependency, weight, exclusive, padding, endOfStream);
diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoder.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoder.java
index 18375db76a..f129f63fd8 100644
--- a/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoder.java
+++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoder.java
@@ -21,10 +21,12 @@ import io.netty.channel.ChannelFutureListener;
 import io.netty.channel.ChannelHandlerContext;
 import io.netty.channel.ChannelPromise;
 import io.netty.channel.CoalescingBufferQueue;
+import io.netty.handler.codec.http.HttpStatusClass;
 import io.netty.util.internal.UnstableApi;
 
 import java.util.ArrayDeque;
 
+import static io.netty.handler.codec.http.HttpStatusClass.INFORMATIONAL;
 import static io.netty.handler.codec.http2.Http2CodecUtil.DEFAULT_PRIORITY_WEIGHT;
 import static io.netty.handler.codec.http2.Http2Error.PROTOCOL_ERROR;
 import static io.netty.handler.codec.http2.Http2Exception.connectionError;
@@ -146,6 +148,15 @@ public class DefaultHttp2ConnectionEncoder implements Http2ConnectionEncoder {
         return writeHeaders(ctx, streamId, headers, 0, DEFAULT_PRIORITY_WEIGHT, false, padding, endStream, promise);
     }
 
+    private static boolean validateHeadersSentState(Http2Stream stream, Http2Headers headers, boolean isServer,
+                                                    boolean endOfStream) {
+        boolean isInformational = isServer && HttpStatusClass.valueOf(headers.status()) == INFORMATIONAL;
+        if ((isInformational || !endOfStream) && stream.isHeadersSent() || stream.isTrailersSent()) {
+            throw new IllegalStateException("Stream " + stream.id() + " sent too many headers EOS: " + endOfStream);
+        }
+        return isInformational;
+    }
+
     @Override
     public ChannelFuture writeHeaders(final ChannelHandlerContext ctx, final int streamId,
             final Http2Headers headers, final int streamDependency, final short weight,
@@ -181,6 +192,7 @@ public class DefaultHttp2ConnectionEncoder implements Http2ConnectionEncoder {
             // for this stream.
             Http2RemoteFlowController flowController = flowController();
             if (!endOfStream || !flowController.hasFlowControlled(stream)) {
+                boolean isInformational = validateHeadersSentState(stream, headers, connection.isServer(), endOfStream);
                 if (endOfStream) {
                     final Http2Stream finalStream = stream;
                     final ChannelFutureListener closeStreamLocalListener = new ChannelFutureListener() {
@@ -191,6 +203,7 @@ public class DefaultHttp2ConnectionEncoder implements Http2ConnectionEncoder {
                     };
                     promise = promise.unvoid().addListener(closeStreamLocalListener);
                 }
+
                 ChannelFuture future = frameWriter.writeHeaders(ctx, streamId, headers, streamDependency,
                                                                 weight, exclusive, padding, endOfStream, promise);
                 // Writing headers may fail during the encode state if they violate HPACK limits.
@@ -198,7 +211,7 @@ public class DefaultHttp2ConnectionEncoder implements Http2ConnectionEncoder {
                 if (failureCause == null) {
                     // Synchronously set the headersSent flag to ensure that we do not subsequently write
                     // other headers containing pseudo-header fields.
-                    stream.headersSent();
+                    stream.headersSent(isInformational);
                 } else {
                     lifecycleManager.onError(ctx, failureCause);
                 }
@@ -452,6 +465,7 @@ public class DefaultHttp2ConnectionEncoder implements Http2ConnectionEncoder {
 
         @Override
         public void write(ChannelHandlerContext ctx, int allowedBytes) {
+            boolean isInformational = validateHeadersSentState(stream, headers, connection.isServer(), endOfStream);
             if (promise.isVoid()) {
                 promise = ctx.newPromise();
             }
@@ -462,7 +476,7 @@ public class DefaultHttp2ConnectionEncoder implements Http2ConnectionEncoder {
             // Writing headers may fail during the encode state if they violate HPACK limits.
             Throwable failureCause = f.cause();
             if (failureCause == null) {
-                stream.headersSent();
+                stream.headersSent(isInformational);
             } else {
                 lifecycleManager.onError(ctx, failureCause);
             }
diff --git a/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2Stream.java b/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2Stream.java
index 167087551b..3b654425cf 100644
--- a/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2Stream.java
+++ b/codec-http2/src/main/java/io/netty/handler/codec/http2/Http2Stream.java
@@ -130,15 +130,41 @@ public interface Http2Stream {
     <V> V removeProperty(Http2Connection.PropertyKey key);
 
     /**
-     * Indicates that headers has been sent to the remote on this stream.
+     * Indicates that headers have been sent to the remote endpoint on this stream. The first call to this method would
+     * be for the initial headers (see {@link #isHeadersSent()}} and the second call would indicate the trailers
+     * (see {@link #isTrailersReceived()}).
+     * @param isInformational {@code true} if the headers contain an informational status code (for responses only).
      */
-    Http2Stream headersSent();
+    Http2Stream headersSent(boolean isInformational);
 
     /**
-     * Indicates whether or not headers was sent to the remote endpoint.
+     * Indicates whether or not headers were sent to the remote endpoint.
      */
     boolean isHeadersSent();
 
+    /**
+     * Indicates whether or not trailers were sent to the remote endpoint.
+     */
+    boolean isTrailersSent();
+
+    /**
+     * Indicates that headers have been received. The first call to this method would be for the initial headers
+     * (see {@link #isHeadersReceived()}} and the second call would indicate the trailers
+     * (see {@link #isTrailersReceived()}).
+     * @param isInformational {@code true} if the headers contain an informational status code (for responses only).
+     */
+    Http2Stream headersReceived(boolean isInformational);
+
+    /**
+     * Indicates whether or not the initial headers have been received.
+     */
+    boolean isHeadersReceived();
+
+    /**
+     * Indicates whether or not the trailers have been received.
+     */
+    boolean isTrailersReceived();
+
     /**
      * Indicates that a push promise was sent to the remote endpoint.
      */
diff --git a/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoderTest.java b/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoderTest.java
index 6dc4266799..3fcf560eff 100644
--- a/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoderTest.java
+++ b/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionDecoderTest.java
@@ -21,6 +21,7 @@ import io.netty.channel.ChannelFuture;
 import io.netty.channel.ChannelHandlerContext;
 import io.netty.channel.ChannelPromise;
 import io.netty.channel.DefaultChannelPromise;
+import io.netty.handler.codec.http.HttpResponseStatus;
 import junit.framework.AssertionFailedError;
 import org.junit.Before;
 import org.junit.Test;
@@ -50,10 +51,10 @@ import static org.mockito.Mockito.anyBoolean;
 import static org.mockito.Mockito.anyInt;
 import static org.mockito.Mockito.anyLong;
 import static org.mockito.Mockito.anyShort;
-import static org.mockito.Mockito.eq;
-import static org.mockito.Mockito.isNull;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.isNull;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -67,6 +68,8 @@ public class DefaultHttp2ConnectionDecoderTest {
     private static final int STREAM_ID = 3;
     private static final int PUSH_STREAM_ID = 2;
     private static final int STREAM_DEPENDENCY_ID = 5;
+    private static final int STATE_RECV_HEADERS = 1;
+    private static final int STATE_RECV_TRAILERS = 1 << 1;
 
     private Http2ConnectionDecoder decoder;
     private ChannelPromise promise;
@@ -122,11 +125,49 @@ public class DefaultHttp2ConnectionDecoderTest {
 
         promise = new DefaultChannelPromise(channel);
 
+        final AtomicInteger headersReceivedState = new AtomicInteger();
         when(channel.isActive()).thenReturn(true);
         when(stream.id()).thenReturn(STREAM_ID);
         when(stream.state()).thenReturn(OPEN);
         when(stream.open(anyBoolean())).thenReturn(stream);
         when(pushStream.id()).thenReturn(PUSH_STREAM_ID);
+        doAnswer(new Answer<Boolean>() {
+            @Override
+            public Boolean answer(InvocationOnMock in) throws Throwable {
+                return (headersReceivedState.get() & STATE_RECV_HEADERS) != 0;
+            }
+        }).when(stream).isHeadersReceived();
+        doAnswer(new Answer<Boolean>() {
+            @Override
+            public Boolean answer(InvocationOnMock in) throws Throwable {
+                return (headersReceivedState.get() & STATE_RECV_TRAILERS) != 0;
+            }
+        }).when(stream).isTrailersReceived();
+        doAnswer(new Answer<Http2Stream>() {
+            @Override
+            public Http2Stream answer(InvocationOnMock in) throws Throwable {
+                boolean isInformational = in.getArgument(0);
+                if (isInformational) {
+                    return stream;
+                }
+                for (;;) {
+                    int current = headersReceivedState.get();
+                    int next = current;
+                    if ((current & STATE_RECV_HEADERS) != 0) {
+                        if ((current & STATE_RECV_TRAILERS) != 0) {
+                            throw new IllegalStateException("already sent headers!");
+                        }
+                        next |= STATE_RECV_TRAILERS;
+                    } else {
+                        next |= STATE_RECV_HEADERS;
+                    }
+                    if (headersReceivedState.compareAndSet(current, next)) {
+                        break;
+                    }
+                }
+                return stream;
+            }
+        }).when(stream).headersReceived(anyBoolean());
         doAnswer(new Answer<Http2Stream>() {
             @Override
             public Http2Stream answer(InvocationOnMock in) throws Throwable {
@@ -452,7 +493,65 @@ public class DefaultHttp2ConnectionDecoderTest {
                 eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(false));
     }
 
+    @Test(expected = Http2Exception.class)
+    public void trailersDoNotEndStreamThrows() throws Exception {
+        decode().onHeadersRead(ctx, STREAM_ID, EmptyHttp2Headers.INSTANCE, 0, false);
+        // Trailers must end the stream!
+        decode().onHeadersRead(ctx, STREAM_ID, EmptyHttp2Headers.INSTANCE, 0, false);
+    }
+
+    @Test(expected = Http2Exception.class)
+    public void tooManyHeadersEOSThrows() throws Exception {
+        tooManyHeaderThrows(true);
+    }
+
+    @Test(expected = Http2Exception.class)
+    public void tooManyHeadersNoEOSThrows() throws Exception {
+        tooManyHeaderThrows(false);
+    }
+
+    private void tooManyHeaderThrows(boolean eos) throws Exception {
+        decode().onHeadersRead(ctx, STREAM_ID, EmptyHttp2Headers.INSTANCE, 0, false);
+        decode().onHeadersRead(ctx, STREAM_ID, EmptyHttp2Headers.INSTANCE, 0, true);
+        // We already received the trailers!
+        decode().onHeadersRead(ctx, STREAM_ID, EmptyHttp2Headers.INSTANCE, 0, eos);
+    }
+
+    private static Http2Headers informationalHeaders() {
+        Http2Headers headers = new DefaultHttp2Headers();
+        headers.status(HttpResponseStatus.CONTINUE.codeAsText());
+        return headers;
+    }
+
+    @Test
+    public void infoHeadersAndTrailersAllowed() throws Exception {
+        infoHeadersAndTrailersAllowed(true, 1);
+    }
+
     @Test
+    public void multipleInfoHeadersAndTrailersAllowed() throws Exception {
+        infoHeadersAndTrailersAllowed(true, 10);
+    }
+
+    @Test(expected = Http2Exception.class)
+    public void infoHeadersAndTrailersNoEOSThrows() throws Exception {
+        infoHeadersAndTrailersAllowed(false, 1);
+    }
+
+    @Test(expected = Http2Exception.class)
+    public void multipleInfoHeadersAndTrailersNoEOSThrows() throws Exception {
+        infoHeadersAndTrailersAllowed(false, 10);
+    }
+
+    private void infoHeadersAndTrailersAllowed(boolean eos, int infoHeaderCount) throws Exception {
+        for (int i = 0; i < infoHeaderCount; ++i) {
+            decode().onHeadersRead(ctx, STREAM_ID, informationalHeaders(), 0, false);
+        }
+        decode().onHeadersRead(ctx, STREAM_ID, EmptyHttp2Headers.INSTANCE, 0, false);
+        decode().onHeadersRead(ctx, STREAM_ID, EmptyHttp2Headers.INSTANCE, 0, eos);
+    }
+
+    @Test()
     public void headersReadForPromisedStreamShouldCloseStream() throws Exception {
         when(stream.state()).thenReturn(RESERVED_REMOTE);
         decode().onHeadersRead(ctx, STREAM_ID, EmptyHttp2Headers.INSTANCE, 0, true);
diff --git a/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoderTest.java b/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoderTest.java
index ac63f6dce9..4c5482ba9e 100644
--- a/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoderTest.java
+++ b/codec-http2/src/test/java/io/netty/handler/codec/http2/DefaultHttp2ConnectionEncoderTest.java
@@ -24,6 +24,7 @@ import io.netty.channel.ChannelHandlerContext;
 import io.netty.channel.ChannelPipeline;
 import io.netty.channel.ChannelPromise;
 import io.netty.channel.DefaultChannelPromise;
+import io.netty.handler.codec.http.HttpResponseStatus;
 import io.netty.handler.codec.http2.Http2RemoteFlowController.FlowControlled;
 import io.netty.util.concurrent.ImmediateEventExecutor;
 import junit.framework.AssertionFailedError;
@@ -59,11 +60,12 @@ import static org.mockito.Mockito.anyBoolean;
 import static org.mockito.Mockito.anyInt;
 import static org.mockito.Mockito.anyLong;
 import static org.mockito.Mockito.anyShort;
-import static org.mockito.Mockito.eq;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
@@ -342,11 +344,208 @@ public class DefaultHttp2ConnectionEncoderTest {
                 eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(false), eq(promise));
     }
 
+    @Test
+    public void trailersDoNotEndStreamThrows() {
+        writeAllFlowControlledFrames();
+        final int streamId = 6;
+        ChannelPromise promise = newPromise();
+        encoder.writeHeaders(ctx, streamId, EmptyHttp2Headers.INSTANCE, 0, false, promise);
+
+        ChannelPromise promise2 = newPromise();
+        ChannelFuture future = encoder.writeHeaders(ctx, streamId, EmptyHttp2Headers.INSTANCE, 0, false, promise2);
+        assertTrue(future.isDone());
+        assertFalse(future.isSuccess());
+
+        verify(writer, times(1)).writeHeaders(eq(ctx), eq(streamId), eq(EmptyHttp2Headers.INSTANCE), eq(0),
+                eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(false), eq(promise));
+    }
+
+    @Test
+    public void trailersDoNotEndStreamWithDataThrows() {
+        writeAllFlowControlledFrames();
+        final int streamId = 6;
+        ChannelPromise promise = newPromise();
+        encoder.writeHeaders(ctx, streamId, EmptyHttp2Headers.INSTANCE, 0, false, promise);
+
+        Http2Stream stream = connection.stream(streamId);
+        when(remoteFlow.hasFlowControlled(eq(stream))).thenReturn(true);
+
+        ChannelPromise promise2 = newPromise();
+        ChannelFuture future = encoder.writeHeaders(ctx, streamId, EmptyHttp2Headers.INSTANCE, 0, false, promise2);
+        assertTrue(future.isDone());
+        assertFalse(future.isSuccess());
+
+        verify(writer, times(1)).writeHeaders(eq(ctx), eq(streamId), eq(EmptyHttp2Headers.INSTANCE), eq(0),
+                eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(false), eq(promise));
+    }
+
+    @Test
+    public void tooManyHeadersNoEOSThrows() {
+        tooManyHeadersThrows(false);
+    }
+
+    @Test
+    public void tooManyHeadersEOSThrows() {
+        tooManyHeadersThrows(true);
+    }
+
+    private void tooManyHeadersThrows(boolean eos) {
+        writeAllFlowControlledFrames();
+        final int streamId = 6;
+        ChannelPromise promise = newPromise();
+        encoder.writeHeaders(ctx, streamId, EmptyHttp2Headers.INSTANCE, 0, false, promise);
+        ChannelPromise promise2 = newPromise();
+        encoder.writeHeaders(ctx, streamId, EmptyHttp2Headers.INSTANCE, 0, true, promise2);
+
+        ChannelPromise promise3 = newPromise();
+        ChannelFuture future = encoder.writeHeaders(ctx, streamId, EmptyHttp2Headers.INSTANCE, 0, eos, promise3);
+        assertTrue(future.isDone());
+        assertFalse(future.isSuccess());
+
+        verify(writer, times(1)).writeHeaders(eq(ctx), eq(streamId), eq(EmptyHttp2Headers.INSTANCE), eq(0),
+                eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(false), eq(promise));
+        verify(writer, times(1)).writeHeaders(eq(ctx), eq(streamId), eq(EmptyHttp2Headers.INSTANCE), eq(0),
+                eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(true), eq(promise2));
+    }
+
+    @Test
+    public void infoHeadersAndTrailersAllowed() throws Exception {
+        infoHeadersAndTrailers(true, 1);
+    }
+
+    @Test
+    public void multipleInfoHeadersAndTrailersAllowed() throws Exception {
+        infoHeadersAndTrailers(true, 10);
+    }
+
+    @Test
+    public void infoHeadersAndTrailersNoEOSThrows() throws Exception {
+        infoHeadersAndTrailers(false, 1);
+    }
+
+    @Test
+    public void multipleInfoHeadersAndTrailersNoEOSThrows() throws Exception {
+        infoHeadersAndTrailers(false, 10);
+    }
+
+    private void infoHeadersAndTrailers(boolean eos, int infoHeaderCount) {
+        writeAllFlowControlledFrames();
+        final int streamId = 6;
+        Http2Headers infoHeaders = informationalHeaders();
+        for (int i = 0; i < infoHeaderCount; ++i) {
+            encoder.writeHeaders(ctx, streamId, infoHeaders, 0, false, newPromise());
+        }
+        ChannelPromise promise2 = newPromise();
+        encoder.writeHeaders(ctx, streamId, EmptyHttp2Headers.INSTANCE, 0, false, promise2);
+
+        ChannelPromise promise3 = newPromise();
+        ChannelFuture future = encoder.writeHeaders(ctx, streamId, EmptyHttp2Headers.INSTANCE, 0, eos, promise3);
+        assertTrue(future.isDone());
+        assertEquals(eos, future.isSuccess());
+
+        verify(writer, times(infoHeaderCount)).writeHeaders(eq(ctx), eq(streamId), eq(infoHeaders), eq(0),
+                eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(false), any(ChannelPromise.class));
+        verify(writer, times(1)).writeHeaders(eq(ctx), eq(streamId), eq(EmptyHttp2Headers.INSTANCE), eq(0),
+                eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(false), eq(promise2));
+        if (eos) {
+            verify(writer, times(1)).writeHeaders(eq(ctx), eq(streamId), eq(EmptyHttp2Headers.INSTANCE), eq(0),
+                    eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(true), eq(promise3));
+        }
+    }
+
+    private static Http2Headers informationalHeaders() {
+        Http2Headers headers = new DefaultHttp2Headers();
+        headers.status(HttpResponseStatus.CONTINUE.codeAsText());
+        return headers;
+    }
+
+    @Test
+    public void tooManyHeadersWithDataNoEOSThrows() {
+        tooManyHeadersWithDataThrows(false);
+    }
+
+    @Test
+    public void tooManyHeadersWithDataEOSThrows() {
+        tooManyHeadersWithDataThrows(true);
+    }
+
+    private void tooManyHeadersWithDataThrows(boolean eos) {
+        writeAllFlowControlledFrames();
+        final int streamId = 6;
+        ChannelPromise promise = newPromise();
+        encoder.writeHeaders(ctx, streamId, EmptyHttp2Headers.INSTANCE, 0, false, promise);
+
+        Http2Stream stream = connection.stream(streamId);
+        when(remoteFlow.hasFlowControlled(eq(stream))).thenReturn(true);
+
+        ChannelPromise promise2 = newPromise();
+        encoder.writeHeaders(ctx, streamId, EmptyHttp2Headers.INSTANCE, 0, true, promise2);
+
+        ChannelPromise promise3 = newPromise();
+        ChannelFuture future = encoder.writeHeaders(ctx, streamId, EmptyHttp2Headers.INSTANCE, 0, eos, promise3);
+        assertTrue(future.isDone());
+        assertFalse(future.isSuccess());
+
+        verify(writer, times(1)).writeHeaders(eq(ctx), eq(streamId), eq(EmptyHttp2Headers.INSTANCE), eq(0),
+                eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(false), eq(promise));
+        verify(writer, times(1)).writeHeaders(eq(ctx), eq(streamId), eq(EmptyHttp2Headers.INSTANCE), eq(0),
+                eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(true), eq(promise2));
+    }
+
+    @Test
+    public void infoHeadersAndTrailersWithDataAllowed() {
+        infoHeadersAndTrailersWithData(true, 1);
+    }
+
+    @Test
+    public void multipleInfoHeadersAndTrailersWithDataAllowed() {
+        infoHeadersAndTrailersWithData(true, 10);
+    }
+
+    @Test
+    public void infoHeadersAndTrailersWithDataNoEOSThrows() {
+        infoHeadersAndTrailersWithData(false, 1);
+    }
+
+    @Test
+    public void multipleInfoHeadersAndTrailersWithDataNoEOSThrows() {
+        infoHeadersAndTrailersWithData(false, 10);
+    }
+
+    private void infoHeadersAndTrailersWithData(boolean eos, int infoHeaderCount) {
+        writeAllFlowControlledFrames();
+        final int streamId = 6;
+        Http2Headers infoHeaders = informationalHeaders();
+        for (int i = 0; i < infoHeaderCount; ++i) {
+            encoder.writeHeaders(ctx, streamId, infoHeaders, 0, false, newPromise());
+        }
+
+        Http2Stream stream = connection.stream(streamId);
+        when(remoteFlow.hasFlowControlled(eq(stream))).thenReturn(true);
+
+        ChannelPromise promise2 = newPromise();
+        encoder.writeHeaders(ctx, streamId, EmptyHttp2Headers.INSTANCE, 0, false, promise2);
+
+        ChannelPromise promise3 = newPromise();
+        ChannelFuture future = encoder.writeHeaders(ctx, streamId, EmptyHttp2Headers.INSTANCE, 0, eos, promise3);
+        assertTrue(future.isDone());
+        assertEquals(eos, future.isSuccess());
+
+        verify(writer, times(infoHeaderCount)).writeHeaders(eq(ctx), eq(streamId), eq(infoHeaders), eq(0),
+                eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(false), any(ChannelPromise.class));
+        verify(writer, times(1)).writeHeaders(eq(ctx), eq(streamId), eq(EmptyHttp2Headers.INSTANCE), eq(0),
+                eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(false), eq(promise2));
+        if (eos) {
+            verify(writer, times(1)).writeHeaders(eq(ctx), eq(streamId), eq(EmptyHttp2Headers.INSTANCE), eq(0),
+                    eq(DEFAULT_PRIORITY_WEIGHT), eq(false), eq(0), eq(true), eq(promise3));
+        }
+    }
+
     @Test
     public void pushPromiseWriteAfterGoAwayReceivedShouldFail() throws Exception {
         createStream(STREAM_ID, false);
         goAwayReceived(0);
-        ChannelFuture future =  encoder.writePushPromise(ctx, STREAM_ID, PUSH_STREAM_ID, EmptyHttp2Headers.INSTANCE, 0,
+        ChannelFuture future = encoder.writePushPromise(ctx, STREAM_ID, PUSH_STREAM_ID, EmptyHttp2Headers.INSTANCE, 0,
                 newPromise());
         assertTrue(future.isDone());
         assertFalse(future.isSuccess());
diff --git a/codec-http2/src/test/java/io/netty/handler/codec/http2/InboundHttp2ToHttpAdapterTest.java b/codec-http2/src/test/java/io/netty/handler/codec/http2/InboundHttp2ToHttpAdapterTest.java
index 28a5c5b44b..33393afc0a 100644
--- a/codec-http2/src/test/java/io/netty/handler/codec/http2/InboundHttp2ToHttpAdapterTest.java
+++ b/codec-http2/src/test/java/io/netty/handler/codec/http2/InboundHttp2ToHttpAdapterTest.java
@@ -360,49 +360,6 @@ public class InboundHttp2ToHttpAdapterTest {
         }
     }
 
-    @Test
-    public void clientRequestMultipleHeaders() throws Exception {
-        boostrapEnv(1, 1, 1);
-        // writeHeaders will implicitly add an END_HEADERS tag each time and so this test does not follow the HTTP
-        // message flow. We currently accept this message flow and just add the second headers to the trailing headers.
-        final String text = "";
-        final ByteBuf content = Unpooled.copiedBuffer(text.getBytes());
-        final FullHttpRequest request = new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET,
-                "/some/path/resource2", content, true);
-        try {
-            HttpHeaders httpHeaders = request.headers();
-            httpHeaders.setInt(HttpConversionUtil.ExtensionHeaderNames.STREAM_ID.text(), 3);
-            httpHeaders.setInt(HttpHeaderNames.CONTENT_LENGTH, text.length());
-            httpHeaders.setShort(HttpConversionUtil.ExtensionHeaderNames.STREAM_WEIGHT.text(), (short) 16);
-            HttpHeaders trailingHeaders = request.trailingHeaders();
-            trailingHeaders.set(of("FoO"), of("goo"));
-            trailingHeaders.set(of("foO2"), of("goo2"));
-            trailingHeaders.add(of("fOo2"), of("goo3"));
-            final Http2Headers http2Headers = new DefaultHttp2Headers().method(new AsciiString("GET")).path(
-                    new AsciiString("/some/path/resource2"));
-            final Http2Headers http2Headers2 = new DefaultHttp2Headers()
-                    .set(new AsciiString("foo"), new AsciiString("goo"))
-                    .set(new AsciiString("foo2"), new AsciiString("goo2"))
-                    .add(new AsciiString("foo2"), new AsciiString("goo3"));
-            runInChannel(clientChannel, new Http2Runnable() {
-                @Override
-                public void run() throws Http2Exception {
-                    clientHandler.encoder().writeHeaders(ctxClient(), 3, http2Headers, 0, false, newPromiseClient());
-                    clientHandler.encoder().writeHeaders(ctxClient(), 3, http2Headers2, 0, false, newPromiseClient());
-                    clientHandler.encoder().writeData(ctxClient(), 3, content.retain(), 0, true, newPromiseClient());
-                    clientChannel.flush();
-                }
-            });
-            awaitRequests();
-            ArgumentCaptor<FullHttpMessage> requestCaptor = ArgumentCaptor.forClass(FullHttpMessage.class);
-            verify(serverListener).messageReceived(requestCaptor.capture());
-            capturedRequests = requestCaptor.getAllValues();
-            assertEquals(request, capturedRequests.get(0));
-        } finally {
-            request.release();
-        }
-    }
-
     @Test
     public void clientRequestTrailingHeaders() throws Exception {
         boostrapEnv(1, 1, 1);
-- 
2.23.0

