Summary
Every envelope flag value the suite exercises is only ever sent on the protocol that defines it. No case sends a flag byte on a protocol where that flag is reserved, so a client that misinterprets one passes the full suite. In one implementation that gap let a body frame override real HTTP/2 trailers, substituting a fabricated grpc-status for the server's actual one.
The coverage matrix
Extracted from every testCases entry in testsuites/*.yaml that sets an explicit envelope flags value (89 cases in total):
| flag |
protocols it is sent on |
meaning there |
0 |
Connect, gRPC, gRPC-Web |
data |
1 |
Connect, gRPC, gRPC-Web |
compressed data |
2 |
Connect only |
END_STREAM |
3 |
Connect only |
compressed END_STREAM |
128 |
gRPC-Web only |
trailer frame |
129 |
gRPC-Web only |
compressed trailer frame |
The two rows that matter are 2 and 128: each is tested only where it is legitimate. Nothing sends 0x80 on plain gRPC or Connect, and nothing sends 0x02 on gRPC or gRPC-Web, even though those bytes are reserved rather than defined in those protocols. The gRPC framing byte in particular admits only 0x00 and 0x01.
What both reference implementations do
Both reject a 0x80-flagged frame on plain gRPC, so there is no ambiguity about the correct behaviour here — unlike the non-200 question in the sibling issue, this one has a clear answer that simply is not pinned. Measured against connect-go v1.19.1 and grpc-go v1.81.1:
- connect-go:
internal — "protocol error: invalid envelope flags 128"
- grpc-go:
Internal — "grpc: received unexpected payload format 128"
What the gap allowed
connectrpc/connect-rust checked the high bit without gating on the protocol, so on plain gRPC it parsed the frame as a gRPC-Web trailer block. Given a response with a valid data envelope, a 0x80 frame whose payload claimed grpc-status: 5, and real HTTP/2 trailers saying grpc-status: 0, the client returned NotFound with the fabricated message. The genuine trailers were never consulted. A peer able to place bytes in the response body could therefore choose the RPC's terminal status.
All six suites passed throughout.
The same class exists for 0x02. On the streaming decode path an END_STREAM-flagged frame is routed to the Connect end-of-stream parser regardless of protocol, so on plain gRPC a body frame carrying {"error":{"code":"permission_denied",...}} is returned as the stream's terminal error. Connect END_STREAM has no meaning in gRPC, where the status arrives in HTTP/2 trailers.
We have fixed the 0x80 case and are fixing 0x02, but both were found by hand-written interop tests rather than by the suite, which is why we are reporting the gap rather than only the bug.
Suggested cases
RawHTTPResponse.stream already takes per-item flags, so these are test data with no harness change.
For each protocol, one case per flag value the protocol does not define, asserting the client reports a protocol/framing error rather than acting on the frame:
- gRPC:
0x02, 0x80, 0x81, and ideally an arbitrary reserved bit such as 0x04.
- gRPC-Web:
0x02 (Connect's END_STREAM has no meaning here).
- Connect:
0x80.
The highest-value single case is the adversarial one, because it is the one where a permissive client is not merely lenient but wrong: on plain gRPC, a valid data envelope, then a 0x80 frame claiming a non-zero grpc-status, then real HTTP/2 trailers with grpc-status: 0. A conformant client must report the framing error or the real trailers, never the frame's claim.
Happy to send a PR with these.
Summary
Every envelope flag value the suite exercises is only ever sent on the protocol that defines it. No case sends a flag byte on a protocol where that flag is reserved, so a client that misinterprets one passes the full suite. In one implementation that gap let a body frame override real HTTP/2 trailers, substituting a fabricated
grpc-statusfor the server's actual one.The coverage matrix
Extracted from every
testCasesentry intestsuites/*.yamlthat sets an explicit envelopeflagsvalue (89 cases in total):0123128129The two rows that matter are
2and128: each is tested only where it is legitimate. Nothing sends0x80on plain gRPC or Connect, and nothing sends0x02on gRPC or gRPC-Web, even though those bytes are reserved rather than defined in those protocols. The gRPC framing byte in particular admits only0x00and0x01.What both reference implementations do
Both reject a
0x80-flagged frame on plain gRPC, so there is no ambiguity about the correct behaviour here — unlike the non-200 question in the sibling issue, this one has a clear answer that simply is not pinned. Measured against connect-go v1.19.1 and grpc-go v1.81.1:internal— "protocol error: invalid envelope flags 128"Internal— "grpc: received unexpected payload format 128"What the gap allowed
connectrpc/connect-rust checked the high bit without gating on the protocol, so on plain gRPC it parsed the frame as a gRPC-Web trailer block. Given a response with a valid data envelope, a
0x80frame whose payload claimedgrpc-status: 5, and real HTTP/2 trailers sayinggrpc-status: 0, the client returnedNotFoundwith the fabricated message. The genuine trailers were never consulted. A peer able to place bytes in the response body could therefore choose the RPC's terminal status.All six suites passed throughout.
The same class exists for
0x02. On the streaming decode path an END_STREAM-flagged frame is routed to the Connect end-of-stream parser regardless of protocol, so on plain gRPC a body frame carrying{"error":{"code":"permission_denied",...}}is returned as the stream's terminal error. Connect END_STREAM has no meaning in gRPC, where the status arrives in HTTP/2 trailers.We have fixed the
0x80case and are fixing0x02, but both were found by hand-written interop tests rather than by the suite, which is why we are reporting the gap rather than only the bug.Suggested cases
RawHTTPResponse.streamalready takes per-item flags, so these are test data with no harness change.For each protocol, one case per flag value the protocol does not define, asserting the client reports a protocol/framing error rather than acting on the frame:
0x02,0x80,0x81, and ideally an arbitrary reserved bit such as0x04.0x02(Connect's END_STREAM has no meaning here).0x80.The highest-value single case is the adversarial one, because it is the one where a permissive client is not merely lenient but wrong: on plain gRPC, a valid data envelope, then a
0x80frame claiming a non-zerogrpc-status, then real HTTP/2 trailers withgrpc-status: 0. A conformant client must report the framing error or the real trailers, never the frame's claim.Happy to send a PR with these.