connectrpc / connectrpc/connect-rust

client: envelope flag bytes are never validated per protocol (0x02 forges a gRPC stream error)

Open
#255 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Rust
Stars
509
Forks
66
Avg merge
2d 8h
Merged PRs (30d)
10

Description

Correction (2026-07-24): the original text below understated this, and got the reassuring half wrong. It said "on the streaming path this is not the trailer-substitution problem #242 fixed" — true for 0x80, and false for 0x02. Rewritten below; the original is kept at the bottom.

Summary

The envelope flag byte is not validated against the protocol at any decode site. Envelope::decode_with_limit reads flags = buf[0] and never rejects a value, and the two flag predicates are bare bit tests:

pub fn is_compressed(&self) -> bool { self.flags & flags::COMPRESSED != 0 }
pub fn is_end_stream(&self) -> bool { self.flags & flags::END_STREAM != 0 }

Two consequences, one of which is worse than the other.

The 0x02 case is a forged-error injection on gRPC streaming

In ServerStream::next_message_or_end (connectrpc/src/client/mod.rs), the END_STREAM branch has no protocol gate, and its own comment admits what it is:

match envelope_result {
    Some(envelope) => {
        if envelope.is_end_stream() {
            // Connect protocol end-of-stream envelope
            return Err(self.process_end_stream(envelope));
        }

END_STREAM is a Connect construct. On gRPC and gRPC-Web the terminal status arrives in HTTP/2 trailers, never in a body frame. But a plain-gRPC response body frame whose flag byte has bit 1 set is routed into process_end_stream, which parses the payload as Connect END_STREAM JSON and returns its error field as the stream's terminal outcome.

So a peer that can put bytes in a gRPC server-streaming response body — a malicious or compromised server, a proxy, a gRPC-to-Connect gateway — can inject an arbitrary status code and message:

02 00 00 00 30 {"error":{"code":"permission_denied","message":"..."}}

The client surfaces that as the terminal error, the real trailers are never read, and ServerStream::trailers() is None. That is the same substitution class #242 closed for 0x80 on unary, on a path where the payload is attacker-chosen rather than merely misparsed.

Note the flag value is not legal gRPC in the first place: the gRPC framing byte is a compressed-flag, 0x00 or 0x01, and everything else is reserved. A conformant server never sends 0x02, so nothing legitimate depends on today's behaviour.

The 0x80 case, as originally reported

After #242, the unary parser rejects a 0x80 flag on plain gRPC as a framing error. The streaming decoder still gates every 0x80 branch on Protocol::GrpcWeb and lets the frame fall through to a normal envelope decode, so the same bytes are a clean error on a unary call and a garbage message on a streaming one. Lower severity than the 0x02 case — a body frame cannot displace the trailers here — but the inconsistency is real.

Proposed fix: validate the whole flag byte, per protocol

Rejecting one bit in one parser is what left the more exploitable sibling open. The fix that closes the class:

  • gRPC and gRPC-Web: the only legal flag values are 0x00 and 0x01 (DATA, COMPRESSED). gRPC-Web additionally admits 0x80 (and 0x81) for its trailer frame. Anything else is a framing error.
  • Connect: 0x00, 0x01, 0x02 and their combination; 0x80 is not a Connect flag.

Apply it at every envelope decode site rather than at the two the reported bugs happened to surface at — the unary parser, ServerStream::next_message_or_end, and the gRPC-Web trailer-frame parser. Now that envelope::flags names its constants this is a small, mechanical change.

Notes

No conformance suite reaches any of this: all six drive a well-behaved reference server, which never sets a reserved flag bit. Needs unit coverage on both the unary and streaming paths, for 0x02 and 0x80, on each protocol.


Original report (superseded above)

0x80 is the gRPC-Web trailer sentinel. After #242 the unary gRPC response parser rejects it on plain gRPC as a framing error; the streaming decoder does not — it hands the frame to Envelope::decode_with_limit, which inspects only bits 0 and 1, and the payload is decoded as an ordinary uncompressed message. So the same malformed input is a clean error on a unary call and silently garbage on a streaming one.

Worth stating up front, because the two look alike: on the streaming path this is not the trailer-substitution problem #242 fixed. Streaming trailers arrive through BodyPoll::Trailers and classify_grpc_end, so a body frame cannot overwrite them. The consequence here is a confusing decode failure or a garbage message rather than a forged status.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with Envelope::decode_with_limit and the flag constants, then trace the unary parser, ServerStream::next_message_or_end, and the gRPC-Web trailer-frame parser. Add unit coverage for 0x02 and 0x80 on each protocol and verify reserved values are rejected consistently as framing errors.

Written by the indexing model from the issue text.

Assessment

Tech stack
rust
Domain
backend-api-design
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.