diff --git a/authbridge/authlib/listener/extproc/server.go b/authbridge/authlib/listener/extproc/server.go index c038a095..8de88d38 100644 --- a/authbridge/authlib/listener/extproc/server.go +++ b/authbridge/authlib/listener/extproc/server.go @@ -669,6 +669,10 @@ func (s *Server) handleResponseBody(ctx context.Context, body []byte, pctx *pipe Response: &extprocv3.ProcessingResponse_ResponseBody{ ResponseBody: &extprocv3.BodyResponse{ Response: &extprocv3.CommonResponse{ + HeaderMutation: &extprocv3.HeaderMutation{ + SetHeaders: []*corev3.HeaderValueOption{contentLength(pctx.ResponseBody)}, + RemoveHeaders: []string{"content-encoding"}, + }, BodyMutation: &extprocv3.BodyMutation{ Mutation: &extprocv3.BodyMutation_Body{ Body: pctx.ResponseBody, @@ -840,8 +844,9 @@ func passBodyResponse() *extprocv3.ProcessingResponse { // withBodyMutation optionally decorates a RequestBody ProcessingResponse // with an ext_proc BodyMutation when the pipeline rewrote pctx.Body. -// Envoy replaces the buffered body with the new bytes and recomputes -// Content-Length for the upstream. We also clear content-encoding +// Envoy replaces the buffered body with the new bytes but, in BUFFERED + +// SEND mode, leaves content-length to the processor (processing_mode.proto, +// BodySendMode) and rejects a mismatch. We also clear content-encoding // because the plugin may have decompressed + rewritten in plaintext; // shipping plain bytes without the old encoding header is safer than // shipping a malformed archive. @@ -867,9 +872,16 @@ func withBodyMutation(resp *extprocv3.ProcessingResponse, pctx *pipeline.Context cr.HeaderMutation = &extprocv3.HeaderMutation{} } cr.HeaderMutation.RemoveHeaders = append(cr.HeaderMutation.RemoveHeaders, "content-encoding") + cr.HeaderMutation.SetHeaders = append(cr.HeaderMutation.SetHeaders, contentLength(pctx.Body)) return resp } +// contentLength is the SetHeaders entry a body-mutation reply must carry in +// BUFFERED + SEND mode (processing_mode.proto, BodySendMode). +func contentLength(body []byte) *corev3.HeaderValueOption { + return &corev3.HeaderValueOption{Header: &corev3.HeaderValue{Key: "content-length", RawValue: []byte(strconv.Itoa(len(body)))}} +} + func allowBodyResponse() *extprocv3.ProcessingResponse { return &extprocv3.ProcessingResponse{ Response: &extprocv3.ProcessingResponse_RequestBody{ diff --git a/authbridge/authlib/listener/extproc/server_contentlength_test.go b/authbridge/authlib/listener/extproc/server_contentlength_test.go new file mode 100644 index 00000000..5100c4b1 --- /dev/null +++ b/authbridge/authlib/listener/extproc/server_contentlength_test.go @@ -0,0 +1,71 @@ +package extproc + +import ( + "context" + "net/http" + "strconv" + "testing" + + "github.com/rossoctl/cortex/authbridge/authlib/pipeline" +) + +// Envoy's ext_proc contract for the mode this listener runs (BUFFERED body, +// SEND headers), from api/envoy/extensions/filters/http/ext_proc/v3/ +// processing_mode.proto, BodySendMode (v1.37.1): +// +// In BUFFERED mode with SEND header mode, content length header is +// allowed but it is external processor's responsibility to set the +// content length correctly matched to the length of mutated body. +// +// These tests pin our side of that contract: a body-mutation reply carries +// content-length equal to the mutated body. They do not exercise Envoy. +// Envoy's side is pinned by its own integration test at the same tag, +// test/extensions/filters/http/ext_proc/ext_proc_integration_test.cc +// MismatchedContentLengthAndBodyLength: BUFFERED + SEND, the processor +// replaces "Replace this!" with "Hello, World!" and sets content-length to +// the wrong value → the upstream is never reached, downstream gets 500. The +// original body below is the same; the replacement is deliberately longer, +// so a reply that merely echoed the original length would fail too. + +func TestWithBodyMutation_SetsContentLength(t *testing.T) { + pctx := &pipeline.Context{Body: []byte("Replace this!")} + pctx.SetBody([]byte("Hello, World! (longer)")) + + hm := withBodyMutation(passBodyResponse(), pctx).GetRequestBody().GetResponse().GetHeaderMutation() + if got, want := mutationHeaderValue(hm, "content-length"), strconv.Itoa(len(pctx.Body)); got != want { + t.Fatalf("content-length = %q, want %q", got, want) + } +} + +func TestHandleResponseBody_SetsContentLength(t *testing.T) { + newBody := []byte("Hello, World! (longer)") + p, err := pipeline.New([]pipeline.Plugin{&responseMutator{newBody}}) + if err != nil { + t.Fatal(err) + } + srv := &Server{OutboundPipeline: pipeline.NewHolder(p)} + pctx := &pipeline.Context{ResponseHeaders: http.Header{"Content-Encoding": {"gzip"}}} + + resp := srv.handleResponseBody(context.Background(), []byte("Replace this!"), pctx, "") + hm := resp.GetResponseBody().GetResponse().GetHeaderMutation() + if got, want := mutationHeaderValue(hm, "content-length"), strconv.Itoa(len(newBody)); got != want { + t.Fatalf("content-length = %q, want %q", got, want) + } + if !mutationRemovesHeader(hm, "content-encoding") { + t.Fatalf("content-encoding not removed: %+v", hm) + } +} + +type responseMutator struct{ newBody []byte } + +func (*responseMutator) Name() string { return "response-mutator" } +func (*responseMutator) Capabilities() pipeline.PluginCapabilities { + return pipeline.PluginCapabilities{WritesBody: true} +} +func (*responseMutator) OnRequest(context.Context, *pipeline.Context) pipeline.Action { + return pipeline.Action{Type: pipeline.Continue} +} +func (m *responseMutator) OnResponse(_ context.Context, pctx *pipeline.Context) pipeline.Action { + pctx.SetResponseBody(m.newBody) + return pipeline.Action{Type: pipeline.Continue} +} diff --git a/authbridge/docs/framework-architecture.md b/authbridge/docs/framework-architecture.md index 648446ee..fcd2d4dc 100644 --- a/authbridge/docs/framework-architecture.md +++ b/authbridge/docs/framework-architecture.md @@ -610,7 +610,7 @@ The flags (not byte-compare) are the source of truth. A rewrite that produces by | Listener | Request body | Response body | |---|---|---| -| `extproc` | `withBodyMutation` helper wraps the `RequestBody ProcessingResponse` with an ext_proc `BodyMutation`. Envoy replaces the buffered body and recomputes `Content-Length`. | Same pattern in the response-body handler. | +| `extproc` | `withBodyMutation` helper wraps the `RequestBody ProcessingResponse` with an ext_proc `BodyMutation`. Envoy replaces the buffered body; in BUFFERED + SEND mode the processor must set `Content-Length` itself (`processing_mode.proto`, `BodySendMode`), so the helper does. | Same pattern in the response-body handler. | | `forwardproxy` | On mutation, rebuild `r.Body` from `pctx.Body`, set `r.ContentLength` + `Content-Length` header. | Replace `resp.Body` + `resp.ContentLength` + `Content-Length`. | | `reverseproxy` | Same as forwardproxy on the inbound request before handing to `httputil.ReverseProxy`. | Same as forwardproxy on the response from the upstream. |