Skip to content

Commit 7233f59

Browse files
authored
Verify that a trailers-only response is well-formed (#685)
Previously, connect-go would accept a trailers-only gRPC response (where the trailers are in the HTTP headers, signaled by the presence of a "grpc-status" key in the headers) and assume it was valid w/out further verification. However, it should reject trailers-only responses that _also_ include response body data and/or HTTP trailers, after the HTTP headers as those are malformed responses.
1 parent 4524c7d commit 7233f59

File tree

3 files changed

+130
-4
lines changed

3 files changed

+130
-4
lines changed

connect_ext_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2307,6 +2307,97 @@ func TestStreamUnexpectedEOF(t *testing.T) {
23072307
}
23082308
}
23092309

2310+
func TestTrailersOnlyErrors(t *testing.T) {
2311+
t.Parallel()
2312+
2313+
head := [3]byte{}
2314+
testcases := []struct {
2315+
name string
2316+
handler http.HandlerFunc
2317+
options []connect.ClientOption
2318+
expectCode connect.Code
2319+
expectMsg string
2320+
}{{
2321+
name: "grpc_body_after_trailers-only",
2322+
options: []connect.ClientOption{connect.WithGRPC()},
2323+
handler: func(responseWriter http.ResponseWriter, _ *http.Request) {
2324+
header := responseWriter.Header()
2325+
header.Set("Content-Type", "application/grpc")
2326+
header.Set("Grpc-Status", "3")
2327+
_, err := responseWriter.Write(head[:])
2328+
assert.Nil(t, err)
2329+
},
2330+
expectCode: connect.CodeInternal,
2331+
expectMsg: fmt.Sprintf("internal: corrupt response: %d extra bytes after trailers-only response", len(head)),
2332+
}, {
2333+
name: "grpc-web_body_after_trailers-only",
2334+
options: []connect.ClientOption{connect.WithGRPCWeb()},
2335+
handler: func(responseWriter http.ResponseWriter, _ *http.Request) {
2336+
header := responseWriter.Header()
2337+
header.Set("Content-Type", "application/grpc-web")
2338+
header.Set("Grpc-Status", "3")
2339+
_, err := responseWriter.Write(head[:])
2340+
assert.Nil(t, err)
2341+
},
2342+
expectCode: connect.CodeInternal,
2343+
expectMsg: fmt.Sprintf("internal: corrupt response: %d extra bytes after trailers-only response", len(head)),
2344+
}, {
2345+
name: "grpc_trailers_after_trailers-only",
2346+
options: []connect.ClientOption{connect.WithGRPC()},
2347+
handler: func(responseWriter http.ResponseWriter, _ *http.Request) {
2348+
header := responseWriter.Header()
2349+
header.Set("Content-Type", "application/grpc")
2350+
header.Set("Grpc-Status", "3")
2351+
responseWriter.WriteHeader(http.StatusOK)
2352+
responseWriter.(http.Flusher).Flush() //nolint:forcetypeassert
2353+
header.Set(http.TrailerPrefix+"Foo", "abc")
2354+
},
2355+
expectCode: connect.CodeInternal,
2356+
expectMsg: "internal: corrupt response from server: gRPC trailers-only response may not contain HTTP trailers",
2357+
}, {
2358+
name: "grpc-web_trailers_after_trailers-only",
2359+
options: []connect.ClientOption{connect.WithGRPCWeb()},
2360+
handler: func(responseWriter http.ResponseWriter, _ *http.Request) {
2361+
header := responseWriter.Header()
2362+
header.Set("Content-Type", "application/grpc-web")
2363+
header.Set("Grpc-Status", "3")
2364+
responseWriter.WriteHeader(http.StatusOK)
2365+
responseWriter.(http.Flusher).Flush() //nolint:forcetypeassert
2366+
header.Set(http.TrailerPrefix+"Foo", "abc")
2367+
},
2368+
expectCode: connect.CodeInternal,
2369+
expectMsg: "internal: corrupt response from server: gRPC trailers-only response may not contain HTTP trailers",
2370+
}}
2371+
for _, testcase := range testcases {
2372+
testcase := testcase
2373+
t.Run(testcase.name, func(t *testing.T) {
2374+
t.Parallel()
2375+
mux := http.NewServeMux()
2376+
mux.HandleFunc("/", func(responseWriter http.ResponseWriter, request *http.Request) {
2377+
_, _ = io.Copy(io.Discard, request.Body)
2378+
testcase.handler(responseWriter, request)
2379+
})
2380+
server := memhttptest.NewServer(t, mux)
2381+
client := pingv1connect.NewPingServiceClient(
2382+
server.Client(),
2383+
server.URL(),
2384+
testcase.options...,
2385+
)
2386+
const upTo = 2
2387+
request := connect.NewRequest(&pingv1.CountUpRequest{Number: upTo})
2388+
request.Header().Set("Test-Case", t.Name())
2389+
stream, err := client.CountUp(context.Background(), request)
2390+
assert.Nil(t, err)
2391+
for i := 0; stream.Receive() && i < upTo; i++ {
2392+
assert.Equal(t, stream.Msg().GetNumber(), 42)
2393+
}
2394+
assert.NotNil(t, stream.Err())
2395+
assert.Equal(t, connect.CodeOf(stream.Err()), testcase.expectCode)
2396+
assert.Equal(t, stream.Err().Error(), testcase.expectMsg)
2397+
})
2398+
}
2399+
}
2400+
23102401
// TestBlankImportCodeGeneration tests that services.connect.go is generated with
23112402
// blank import statements to services.pb.go so that the service's Descriptor is
23122403
// available in the global proto registry.

envelope.go

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -305,9 +305,6 @@ func (r *envelopeReader) Read(env *envelope) *Error {
305305
return NewError(CodeUnknown, err)
306306
}
307307
err = wrapIfContextError(err)
308-
if connectErr, ok := asError(err); ok {
309-
return connectErr
310-
}
311308
// Something else has gone wrong - the stream didn't end cleanly.
312309
if connectErr, ok := asError(err); ok {
313310
return connectErr

protocol_grpc.go

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -674,11 +674,16 @@ func grpcValidateResponse(
674674
)
675675
}
676676
// When there's no body, gRPC and gRPC-Web servers may send error information
677-
// in the HTTP headers.
677+
// in the HTTP headers. When this happens, it's called a "trailers-only" response.
678678
if err := grpcErrorFromTrailer(
679679
protobuf,
680680
response.Header,
681681
); err != nil && !errors.Is(err, errTrailersWithoutGRPCStatus) {
682+
// Trailers-only responses may not have data in the body or HTTP trailers.
683+
if bodyErr := grpcVerifyTrailersOnly(response); bodyErr != nil {
684+
return bodyErr
685+
}
686+
682687
// Per the specification, only the HTTP status code and Content-Type should
683688
// be treated as headers. The rest should be treated as trailing metadata.
684689
if contentType := getHeaderCanonical(response.Header, headerContentType); contentType != "" {
@@ -696,6 +701,39 @@ func grpcValidateResponse(
696701
return nil
697702
}
698703

704+
func grpcVerifyTrailersOnly(response *http.Response) *Error {
705+
// Make sure there's nothing in the body.
706+
if numBytes, err := discard(response.Body); err != nil {
707+
err = wrapIfContextError(err)
708+
if connErr, ok := asError(err); ok {
709+
return connErr
710+
}
711+
return errorf(CodeInternal, "corrupt response: I/O error after trailers-only response: %w", err)
712+
} else if numBytes > 0 {
713+
return errorf(CodeInternal, "corrupt response: %d extra bytes after trailers-only response", numBytes)
714+
}
715+
716+
// Now we know we've reached EOF, so we can look at HTTP trailers.
717+
// If headers included "Trailer" key, net/http pre-populates response.Trailer with nil
718+
// values. So we need to exclude those to see if there were actually any trailers.
719+
var trailerCount int
720+
for _, v := range response.Trailer {
721+
if len(v) > 0 {
722+
trailerCount++
723+
}
724+
}
725+
if trailerCount > 0 {
726+
// Invalid response: cannot have both trailers in the header (trailers-only
727+
// response) AND trailers after the body.
728+
return errorf(
729+
CodeInternal,
730+
"corrupt response from server: gRPC trailers-only response may not contain HTTP trailers",
731+
)
732+
}
733+
734+
return nil
735+
}
736+
699737
func grpcHTTPToCode(httpCode int) Code {
700738
// https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md
701739
// Note that this is not just the inverse of the gRPC-to-HTTP mapping.

0 commit comments

Comments
 (0)