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
6 changes: 6 additions & 0 deletions docs/WEB_COVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ the upstream cannot be reached. Consequently, an upstream that requires an
`Authorization` request header is not suitable without a separate authorized
front end.

Response credential filtering also covers trailers, including fields that an
upstream adds only when its body ends. Ordinary end-to-end response trailers
remain available, and the body is still streamed rather than buffered in full.
This is defensive handling of upstream metadata, not an additional tunnel
authentication mechanism.

In web mode:

- `--listen` is the UDP/H3 bind address and `--tcp-listen` is the TCP/H1/H2
Expand Down
50 changes: 50 additions & 0 deletions internal/cover/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
)

var hopByHopHeaders = [...]string{
Expand Down Expand Up @@ -83,6 +84,11 @@ func NewReverseProxyHandler(origin *url.URL, transport http.RoundTripper) (http.
},
ModifyResponse: func(response *http.Response) error {
removeUnsafeHeaders(response.Header)
removeUnsafeHeaders(response.Trailer)
// An upgraded body is duplex, not an HTTP message with trailers.
if response.Body != nil && response.StatusCode != http.StatusSwitchingProtocols {
response.Body = &responseTrailerBody{body: response.Body, response: response}
}
return nil
},
ErrorHandler: func(w http.ResponseWriter, _ *http.Request, _ error) {
Expand All @@ -93,6 +99,50 @@ func NewReverseProxyHandler(origin *url.URL, transport http.RoundTripper) (http.
return proxy, nil
}

// responseTrailerBody filters fields that a transport discovers only at EOF
// or Close, including replacement Trailer maps. It does not buffer the body.
// Trailer must not be inspected while Read is in progress. Close may interrupt
// that Read, so neither I/O operation holds mu: defer cleanup until concurrent
// operations have returned rather than blocking Close on the reader.
type responseTrailerBody struct {
body io.ReadCloser
response *http.Response
mu sync.Mutex
active int
pending bool
}

func (b *responseTrailerBody) Read(p []byte) (int, error) {
b.beginOperation()
n, err := b.body.Read(p)
b.finishOperation(err != nil)
return n, err
}

func (b *responseTrailerBody) Close() error {
b.beginOperation()
err := b.body.Close()
b.finishOperation(true)
return err
}

func (b *responseTrailerBody) beginOperation() {
b.mu.Lock()
b.active++
b.mu.Unlock()
}

func (b *responseTrailerBody) finishOperation(terminal bool) {
b.mu.Lock()
defer b.mu.Unlock()
b.active--
b.pending = b.pending || terminal
if b.active == 0 && b.pending {
removeUnsafeHeaders(b.response.Trailer)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve late ordinary trailers when filtering replacement maps

When the supplied RoundTripper replaces Response.Trailer, this cleanup can make the final map's length equal the number of initially announced trailers even though its keys changed—for example, {X-Old} becomes {Authorization, X-New}, then filtering leaves {X-New}. httputil.ReverseProxy uses that length equality to assume the declarations are unchanged and copies X-New as an ordinary header after WriteHeader, so it is discarded on the wire; the safe late trailer is therefore lost. Preserve the original safe declarations or otherwise ensure changed trailer key sets take the late-trailer path.

Useful? React with 👍 / 👎.

b.pending = false
}
}

func normalizeOrigin(origin *url.URL) (*url.URL, error) {
if origin == nil {
return nil, errors.New("reverse proxy origin is required")
Expand Down
93 changes: 93 additions & 0 deletions internal/cover/response_cancel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package cover

import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"time"
)

func TestReverseProxyResponseBodyCancellationReachesOrigin(t *testing.T) {
canceled := make(chan struct{})
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Trailer", "X-End")
_, _ = io.WriteString(w, "x")
w.(http.Flusher).Flush()
<-r.Context().Done()
close(canceled)
}))
t.Cleanup(origin.Close)
originURL, err := url.Parse(origin.URL)
if err != nil {
t.Fatal(err)
}
upstream := &http.Transport{Proxy: nil}
t.Cleanup(upstream.CloseIdleConnections)
bodyClosed := make(chan struct{})
var closeOnce sync.Once
handler, err := NewReverseProxyHandler(originURL, roundTripFunc(func(r *http.Request) (*http.Response, error) {
response, err := upstream.RoundTrip(r)
if err != nil {
return nil, err
}
body := response.Body
response.Body = &trailerTestBody{read: body.Read, close: func() error {
err := body.Close()
closeOnce.Do(func() { close(bodyClosed) })
return err
}}
return response, nil
}))
if err != nil {
t.Fatal(err)
}
frontDone := make(chan struct{})
front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer close(frontDone)
handler.ServeHTTP(w, r)
}))
t.Cleanup(front.Close)
transport := &http.Transport{Proxy: nil}
t.Cleanup(transport.CloseIdleConnections)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
t.Cleanup(cancel)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, front.URL, nil)
if err != nil {
t.Fatal(err)
}
response, err := (&http.Client{Transport: transport}).Do(request)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = response.Body.Close() })
first := make([]byte, 1)
if _, err := io.ReadFull(response.Body, first); err != nil || first[0] != 'x' {
t.Fatalf("initial streamed byte = %q, error %v", first, err)
}
readDone := make(chan error, 1)
go func() { _, err := response.Body.Read(make([]byte, 1)); readDone <- err }()
cancel()
select {
case err := <-readDone:
if err == nil {
t.Fatal("body read succeeded after cancellation")
}
case <-time.After(time.Second):
t.Fatal("response body read did not stop after cancellation")
}
for name, done := range map[string]<-chan struct{}{
"upstream request": canceled,
"upstream body": bodyClosed,
"front handler": frontDone,
} {
select {
case <-done:
case <-time.After(time.Second):
t.Fatalf("client cancellation did not release %s", name)
}
}
}
Loading
Loading