Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions authbridge/authlib/listener/extproc/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this is now the only site in the file that emits a response-phase HeaderMutation, and it is deliberately self-contained — withHeaderMutation returns early for the response phase (default: return resp, server.go:767), so plugin-made response header edits are still not propagated.

A one-line comment saying so would help: the request path composes (withHeaderMutation then withBodyMutation append into one HeaderMutation), and a future reader could reasonably assume the same symmetry holds here and that appending to this mutation would pick up plugin edits. It would not.

SetHeaders: []*corev3.HeaderValueOption{contentLength(pctx.ResponseBody)},
RemoveHeaders: []string{"content-encoding"},
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
BodyMutation: &extprocv3.BodyMutation{
Mutation: &extprocv3.BodyMutation_Body{
Body: pctx.ResponseBody,
Expand Down Expand Up @@ -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.
Expand All @@ -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{
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

want is derived from pctx.Body, the same field the implementation reads. That makes this assertion unable to fail on a SetBody that flips bodyMutated without updating Body — which is the exact bug class this PR exists to fix: a content-length that disagrees with the bytes actually shipped.

TestHandleResponseBody_SetsContentLength below already gets this right by holding the replacement in a variable and asserting against that. Suggest the same shape here so both tests pin a value independent of the code under test:

newBody := []byte("Hello, World! (longer)")
pctx := &pipeline.Context{Body: []byte("Replace this!")}
pctx.SetBody(newBody)

hm := withBodyMutation(passBodyResponse(), pctx).GetRequestBody().GetResponse().GetHeaderMutation()
if got, want := mutationHeaderValue(hm, "content-length"), strconv.Itoa(len(newBody)); got != want {

Not blocking — the test still pins the regression it was written for (before this PR there was no content-length entry at all).

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}
}
2 changes: 1 addition & 1 deletion authbridge/docs/framework-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
Loading