diff --git a/docs/WEB_COVER.md b/docs/WEB_COVER.md index 7531091..b030b34 100644 --- a/docs/WEB_COVER.md +++ b/docs/WEB_COVER.md @@ -211,6 +211,27 @@ the upstream cannot be reached. Consequently, an upstream that requires an `Authorization` request header is not suitable without a separate authorized front end. +Source builds after v1.0.1 preserve declared end-to-end request trailers for +nonempty streamed uploads through the fixed-origin and public-origin proxies. +For example, a website can receive a late `Content-Digest` after consuming the +upload. Values become available only after successful body EOF; the proxy does +not buffer the complete upload, verify its digest, or promote trailers into +headers. HTTP/2 and HTTP/3 uploads with a positive length use chunked framing +when forwarded to HTTP/1.1 so that their trailers remain available. The incoming +body's length checks still apply. Automatic body replay is disabled for these +trailer-bearing upstream requests. + +Both proxy modes remove invalid, hop-by-hop, connection-nominated and credential +trailer fields, including `Authorization`, `Proxy-Authorization` and +`Proxy-Authentication-Info`, at declaration and EOF. Public-origin mode also +removes untrusted forwarding trailers at EOF. Only initially declared safe +fields are forwarded; late unannounced fields, empty-content trailer exchanges +and custom transports that deep-clone the outgoing request again are not +covered by this compatibility guarantee. Custom request bodies must finalize +trailers before `Read` returns EOF, not mutate them concurrently from `Close`. +Static cover, authenticated tunnel payloads and the published v1.0.1 binary +are unchanged. This is upload compatibility, not a traffic-identification claim. + Source builds after v1.0.1 disable automatic compression negotiation and response decompression on the proxy's private default upstream transport. The visitor's `Accept-Encoding` remains unchanged: explicitly requested gzip still works, and diff --git a/internal/cover/handler.go b/internal/cover/handler.go index ac31d6b..52ac9cc 100644 --- a/internal/cover/handler.go +++ b/internal/cover/handler.go @@ -71,15 +71,12 @@ func newReverseProxyHandler(target *url.URL, transport http.RoundTripper, public // nomination boundary, and restore only our validated H1 WebSocket. removeConnectionNominatedHeaders(request.Out.Header, request.In.Header) removeUnsafeHeaders(request.Out.Header) + prepareRequestTrailers(request.In, request.Out, publicAuthority != "") if publicAuthority != "" { // The URL still chooses the fixed upstream dial and TLS target. // Only this opt-in mode supplies a configured public vhost and // trusted forwarding metadata, after all request nominations. removePublicOriginForwardingHeaders(request.Out.Header) - // Replayed bodies can already have populated Trailer values. - // ReverseProxy owns this cloned map; do not trust those fields - // or mutate the original request's body or trailer map. - removePublicOriginForwardingHeaders(request.Out.Trailer) request.Out.Host = publicAuthority request.Out.Header.Set("X-Forwarded-Host", publicAuthority) request.Out.Header.Set("X-Forwarded-Proto", "https") diff --git a/internal/cover/request_trailer.go b/internal/cover/request_trailer.go new file mode 100644 index 0000000..ffeb091 --- /dev/null +++ b/internal/cover/request_trailer.go @@ -0,0 +1,136 @@ +package cover + +import ( + "io" + "net/http" + "sync" + + "golang.org/x/net/http/httpguts" +) + +// CloneRequestForCover owns the request's metadata without buffering its body. +// Unlike Request.Clone alone, declared trailers are copied into the owned map +// when the shared body reaches EOF, even if the server replaces Trailer then. +// The relay credential is never delegated to the website handler. Ordinary +// Authorization retains the handler's existing policy; the reverse proxy +// applies its stricter upstream policy separately. +// +// As with an HTTP request body, one reader may run concurrently with Close. +// Trailer values must be finalized before Read returns EOF, not by Close. +func CloneRequestForCover(request *http.Request) *http.Request { + clone := request.Clone(request.Context()) + deleteHeaderFold(clone.Header, "Proxy-Authorization") + deleteHeaderFold(clone.Trailer, "Proxy-Authorization") + clone.Body = bridgeRequestTrailers(clone.Body, request, clone.Trailer, nil) + return clone +} + +// ReverseProxy cloned the metadata but still shares In.Body. Preserve that +// second ownership boundary, including the original hop nominations which +// ReverseProxy removed from Out.Header before calling Rewrite. +func prepareRequestTrailers(in, out *http.Request, publicOrigin bool) { + original := originalRequestTrailerSource(in) + nominations := collectConnectionNominations(in.Header, in.Trailer, original.Trailer) + filter := func(destination, current http.Header) { + // An earlier bridge intentionally does not copy unannounced fields. + // Still retain late Connection policy from its original source, without + // forwarding that field or any other undeclared metadata. + removeUnsafeHeadersWithNominations(destination, nominations, collectConnectionNominations(current, original.Trailer)) + for key := range destination { + if !httpToken(key) || !httpguts.ValidTrailerHeader(key) { + delete(destination, key) + } + } + if publicOrigin { + removePublicOriginForwardingHeaders(destination) + } + } + filter(out.Trailer, in.Trailer) + if len(out.Trailer) == 0 || out.Body == nil || out.Body == http.NoBody { + return + } + out.Body = bridgeRequestTrailers(out.Body, in, out.Trailer, filter) + // H2/H3 may declare trailers alongside a positive Content-Length. H1's + // writer discards trailers unless framing is chunked; choose that framing + // explicitly without weakening the original incoming body's length checks. + out.ContentLength = -1 + out.TransferEncoding = []string{"chunked"} + deleteHeaderFold(out.Header, "Content-Length") + // Automatic body replay must not reuse a getter that bypasses this bridge. + // Server requests normally have no GetBody; the caller's getter is untouched. + out.GetBody = nil +} + +func bridgeRequestTrailers(body io.ReadCloser, source *http.Request, destination http.Header, filter func(http.Header, http.Header)) io.ReadCloser { + if body == nil || body == http.NoBody || len(destination) == 0 { + return body + } + declared := make(map[string]string, len(destination)) + var scratch [64]byte + folded := scratch[:0] + for key := range destination { + if !httpToken(key) { + continue + } + folded = foldASCIIHeaderName(folded, key) + declared[string(folded)] = http.CanonicalHeaderKey(key) + } + return &requestTrailerBody{body: body, source: source, original: originalRequestTrailerSource(source), destination: destination, declared: declared, filter: filter} +} + +// Only our own bridge establishes this immutable ownership link. Do not infer +// an original source from arbitrary caller context or a custom transport. +func originalRequestTrailerSource(request *http.Request) *http.Request { + if body, ok := request.Body.(*requestTrailerBody); ok { + return body.original + } + return request +} + +// Publication must finish before EOF is returned: the transport can write +// trailers immediately afterwards and already holds destination's map identity. +// Close does not inspect metadata or take a reader lock, so it can interrupt a +// blocked Read. The underlying HTTP body owns EOF/Close synchronization; custom +// bodies that concurrently mutate Trailer from Close violate this EOF contract. +type requestTrailerBody struct { + body io.ReadCloser + source *http.Request + original *http.Request + destination http.Header + declared map[string]string + filter func(http.Header, http.Header) + once sync.Once +} + +func (b *requestTrailerBody) Read(p []byte) (int, error) { + n, err := b.body.Read(p) + if err == io.EOF { + b.once.Do(func() { + current := b.source.Trailer // H3 can replace the entire map at EOF. + clear(b.destination) + for _, key := range b.declared { + b.destination[key] = nil + } + // One source scan is linear in the number of fields. A logical + // declaration owns one output key even if a custom map contains + // multiple ASCII case aliases; values must not be multiplied. + var scratch [64]byte + folded := scratch[:0] + for field, values := range current { + if !httpToken(field) { + continue + } + folded = foldASCIIHeaderName(folded, field) + if key, declared := b.declared[string(folded)]; declared { + b.destination[key] = append(b.destination[key], values...) + } + } + if b.filter != nil { + b.filter(b.destination, current) + } + }) + } + return n, err +} + +func (b *requestTrailerBody) Close() error { return b.body.Close() } diff --git a/internal/cover/request_trailer_test.go b/internal/cover/request_trailer_test.go new file mode 100644 index 0000000..5ef862d --- /dev/null +++ b/internal/cover/request_trailer_test.go @@ -0,0 +1,652 @@ +package cover + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestCloneRequestForCover(t *testing.T) { + t.Run("owned_maps_and_values", func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "https://public.example/upload", nil) + request.Header = http.Header{ + "Authorization": {"website-header"}, "X-End": {"original-header"}, + "Proxy-Authorization": {"fictional-canonical"}, "proxy-authorization": {"fictional-alias"}, + } + request.Trailer = http.Header{ + "X-End": {"original-trailer"}, "Authorization": {"website-trailer"}, + "Proxy-Authorization": {"fictional-canonical"}, "proxy-authorization": {"fictional-alias"}, + } + beforeHeader, beforeTrailer := request.Header.Clone(), request.Trailer.Clone() + clone := CloneRequestForCover(request) + for _, fields := range []http.Header{clone.Header, clone.Trailer} { + requestTrailerAssertAbsent(t, fields, "Proxy-Authorization") + } + if clone.Header.Get("Authorization") != "website-header" || clone.Trailer.Get("Authorization") != "website-trailer" { + t.Error("cover cloning changed the custom website's ordinary Authorization policy") + } + clone.Header["X-End"][0] = "changed-clone-header" + clone.Trailer["X-End"][0] = "changed-clone-trailer" + clone.Header.Set("X-Only-Clone", "owned") + clone.Trailer.Set("X-Only-Clone", "owned") + if !reflect.DeepEqual(request.Header, beforeHeader) || !reflect.DeepEqual(request.Trailer, beforeTrailer) { + t.Error("clone filtering or value mutations changed caller-owned maps/slices") + } + }) + t.Run("two_clone_layers_follow_replacement", func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "https://public.example/upload", nil) + request.Header.Set("Content-Length", "10") + request.Header.Set("Proxy-Authorization", "fictional-header") + request.Trailer = http.Header{"X-End": nil, "Proxy-Authorization": nil} + initial := request.Trailer + beforeInitial := initial.Clone() + request.ContentLength = 10 + request.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("first-last")), nil } + reads := 0 + request.Body = &requestTrailerUnitBody{read: func(p []byte) (int, error) { + reads++ + if reads == 1 { + return copy(p, "first-"), nil + } + // The pinned H3 server replaces Request.Trailer rather than filling + // the initial map. Both clone layers must follow the current source. + request.Trailer = http.Header{"X-End": {"retained"}, "Proxy-Authorization": {"fictional-late"}} + return copy(p, "last"), io.EOF + }} + coverRequest := CloneRequestForCover(request) + out := coverRequest.Clone(coverRequest.Context()) + prepareRequestTrailers(coverRequest, out, false) + owned := out.Trailer + if reads != 0 { + t.Error("preparing trailers eagerly read the upload body") + } + requestTrailerAssertFraming(t, out) + buffer := make([]byte, 16) + n, err := out.Body.Read(buffer) + if n != 6 || err != nil || string(buffer[:n]) != "first-" || reads != 1 { + t.Fatalf("first streaming read = %d/%v/%q, reads=%d", n, err, buffer[:n], reads) + } + if owned.Get("X-End") != "" { + t.Error("trailer values were published before EOF") + } + n, err = out.Body.Read(buffer) + if n != 4 || err != io.EOF || string(buffer[:n]) != "last" { + t.Fatalf("terminal byte+EOF read = %d/%v/%q", n, err, buffer[:n]) + } + if owned.Get("X-End") != "retained" || coverRequest.Trailer.Get("X-End") != "retained" { + t.Error("EOF did not update the originally owned destination maps") + } + requestTrailerAssertAbsent(t, out.Trailer, "Proxy-Authorization") + requestTrailerAssertAbsent(t, coverRequest.Trailer, "Proxy-Authorization") + if !reflect.DeepEqual(initial, beforeInitial) || request.Header.Get("Proxy-Authorization") != "fictional-header" || + request.Trailer.Get("Proxy-Authorization") != "fictional-late" || request.ContentLength != 10 || request.GetBody == nil { + t.Error("proxy filtering changed caller-owned initial or terminal metadata") + } + request.Trailer["X-End"][0] = "changed-source" + if coverRequest.Trailer.Get("X-End") != "retained" || out.Trailer.Get("X-End") != "retained" { + t.Error("source terminal values alias a clone's copied values") + } + coverRequest.Trailer["X-End"][0] = "changed-inner-clone" + if out.Trailer.Get("X-End") != "retained" { + t.Error("the two clone layers share trailer value slices") + } + }) + t.Run("two_layers_preserve_undeclared_terminal_nominations", func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "https://public.example/upload", nil) + request.Trailer = http.Header{"X-End": nil, "X-Late-Hop": nil} + request.Body = &requestTrailerUnitBody{read: func(p []byte) (int, error) { + request.Trailer = http.Header{ + // Connection itself was never declared and must not be copied + // into either clone. Its nomination still constrains the outer + // reverse-proxy trailer policy at successful EOF. + "Connection": {"X-Late-Hop"}, "X-Late-Hop": {"fictional-hop"}, "X-End": {"retained"}, + } + return copy(p, "x"), io.EOF + }} + inner := CloneRequestForCover(request) + out := inner.Clone(inner.Context()) + prepareRequestTrailers(inner, out, false) + body, err := io.ReadAll(out.Body) + if err != nil || string(body) != "x" || out.Trailer.Get("X-End") != "retained" { + t.Fatalf("nested upload control failed: %q/%v", body, err) + } + requestTrailerAssertAbsent(t, inner.Trailer, "Connection") + requestTrailerAssertAbsent(t, out.Trailer, "Connection", "X-Late-Hop") + if request.Trailer.Get("Connection") != "X-Late-Hop" || request.Trailer.Get("X-Late-Hop") != "fictional-hop" { + t.Error("terminal policy filtering modified the original request") + } + }) + t.Run("case_aliases_are_copied_once", func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "https://public.example/upload", nil) + request.Trailer = http.Header{"X-End": nil, "x-end": nil} + initial := request.Trailer + beforeInitial := initial.Clone() + request.Body = &requestTrailerUnitBody{read: func(p []byte) (int, error) { + request.Trailer = http.Header{"X-End": {"canonical-value"}, "x-end": {"alias-value"}} + return copy(p, "x"), io.EOF + }} + inner := CloneRequestForCover(request) + out := inner.Clone(inner.Context()) + prepareRequestTrailers(inner, out, false) + body, err := io.ReadAll(out.Body) + if err != nil || string(body) != "x" { + t.Fatalf("case-alias upload control failed: %q/%v", body, err) + } + for _, destination := range []http.Header{inner.Trailer, out.Trailer} { + values := append([]string(nil), destination.Values("X-End")...) + sort.Strings(values) // Never assume an order across distinct map fields. + if len(destination) != 1 || !reflect.DeepEqual(values, []string{"alias-value", "canonical-value"}) { + t.Errorf("case-alias values duplicated or declarations remained split: %v", destination) + } + } + if !reflect.DeepEqual(initial, beforeInitial) || len(request.Trailer) != 2 || + !reflect.DeepEqual(request.Trailer["X-End"], []string{"canonical-value"}) || !reflect.DeepEqual(request.Trailer["x-end"], []string{"alias-value"}) { + t.Error("canonicalizing outbound aliases changed the original source maps/values") + } + }) +} + +func TestPrepareRequestTrailersMetadataPolicy(t *testing.T) { + for _, publicOrigin := range []bool{false, true} { + t.Run(fmt.Sprintf("public_origin_%t", publicOrigin), func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "https://public.example/upload", nil) + request.Header = http.Header{"Connection": {"X-From-Header"}, "Content-Length": {"1"}} + request.ContentLength = 1 + request.Trailer = http.Header{ + "Connection": {"X-From-Initial-Trailer"}, "X-From-Header": nil, "X-From-Initial-Trailer": nil, + "X-Late-Hop": nil, "X-End": nil, "X-Missing": {"initial-only"}, + "Authentication-Info": nil, "WWW-Authenticate": nil, + "Host": nil, "Content-Length": nil, "If-Match": nil, "bad field": nil, "X-ſafe": nil, + "Authorization": nil, "proxy-authorization": nil, "Proxy-Authenticate": nil, + "Proxy-Authentication-Info": nil, "Proxy-Connection": nil, "Keep-Alive": nil, + "Te": nil, "Trailer": nil, "Transfer-Encoding": nil, "Upgrade": nil, + "Forwarded": nil, "X-Forwarded-For": nil, "x-forwarded-proto": nil, "X-Real-IP": nil, + } + initial := request.Trailer + beforeHeader, beforeInitial := request.Header.Clone(), initial.Clone() + request.Body = &requestTrailerUnitBody{read: func(p []byte) (int, error) { + request.Trailer = http.Header{ + "Connection": {"X-Late-Hop"}, "X-From-Header": {"fictional-initial-nominee"}, + "X-From-Initial-Trailer": {"fictional-trailer-nominee"}, "X-Late-Hop": {"fictional-late-nominee"}, + "X-End": {"retained"}, "X-Undeclared": {"not-supported"}, + "Authentication-Info": {"ordinary-auth-info"}, "WWW-Authenticate": {"ordinary-challenge"}, + "Authorization": {"fictional"}, "proxy-authorization": {"fictional"}, + "Proxy-Authenticate": {"fictional"}, "Proxy-Authentication-Info": {"fictional"}, + "Proxy-Connection": {"fictional"}, "Keep-Alive": {"fictional"}, "Te": {"fictional"}, + "Trailer": {"fictional"}, "Transfer-Encoding": {"fictional"}, "Upgrade": {"fictional"}, + "Forwarded": {"host=fictional.example"}, "X-Forwarded-For": {"192.0.2.1"}, + "x-forwarded-proto": {"fictional"}, "X-Real-IP": {"192.0.2.2"}, + } + return copy(p, "x"), io.EOF + }} + out := request.Clone(request.Context()) + prepareRequestTrailers(request, out, publicOrigin) + owned := out.Trailer + unsafe := []string{"Connection", "Authorization", "Proxy-Authorization", "Proxy-Authenticate", "Proxy-Authentication-Info", "Proxy-Connection", "Keep-Alive", "Te", "Trailer", "Transfer-Encoding", "Upgrade", "X-From-Header", "X-From-Initial-Trailer", "WWW-Authenticate", "Host", "Content-Length", "If-Match", "bad field", "X-ſafe"} + requestTrailerAssertAbsent(t, out.Trailer, unsafe...) + if publicOrigin { + requestTrailerAssertAbsent(t, out.Trailer, "Forwarded", "X-Forwarded-For", "X-Forwarded-Proto", "X-Real-IP") + } + body, err := io.ReadAll(out.Body) + if err != nil || string(body) != "x" { + t.Fatalf("upload body changed: %q/%v", body, err) + } + requestTrailerAssertAbsent(t, out.Trailer, append(unsafe, "X-Late-Hop", "X-Undeclared")...) + if owned.Get("X-End") != "retained" || out.Trailer.Get("Authentication-Info") != "ordinary-auth-info" || out.Trailer.Get("X-Missing") != "" { + t.Errorf("allowed declared trailer values changed or stale value survived: %v", out.Trailer) + } + if publicOrigin { + requestTrailerAssertAbsent(t, out.Trailer, "Forwarded", "X-Forwarded-For", "X-Forwarded-Proto", "X-Real-IP") + } else if out.Trailer.Get("Forwarded") != "host=fictional.example" || out.Trailer.Get("X-Forwarded-For") != "192.0.2.1" { + t.Error("default mode acquired the opt-in forwarding metadata policy") + } + if !reflect.DeepEqual(request.Header, beforeHeader) || !reflect.DeepEqual(initial, beforeInitial) || + request.Trailer.Get("Authorization") != "fictional" || request.Trailer.Get("X-Undeclared") != "not-supported" { + t.Error("request trailer policy mutated source metadata") + } + }) + } +} + +func TestPrepareRequestTrailersNoBodyOrDeclaration(t *testing.T) { + for _, mode := range []string{"nil_body", "http_no_body", "no_declaration", "reverse_proxy_zero_length_nil_body"} { + t.Run(mode, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "https://public.example/upload", nil) + request.Header.Set("Proxy-Authorization", "fictional") + request.Trailer = http.Header{"X-End": nil} + request.ContentLength = 7 + request.Body = &requestTrailerUnitBody{} + switch mode { + case "nil_body": + request.Body = nil + case "http_no_body": + request.Body = http.NoBody + case "no_declaration": + request.Trailer = nil + case "reverse_proxy_zero_length_nil_body": + request.ContentLength = 0 + } + clone := CloneRequestForCover(request) + if mode != "reverse_proxy_zero_length_nil_body" && clone.Body != request.Body { + t.Error("cover clone unnecessarily wrapped a body without supported trailers") + } + out := clone.Clone(clone.Context()) + if mode == "reverse_proxy_zero_length_nil_body" { + // ReverseProxy deliberately drops this Body before Rewrite. This + // iteration does not restore empty-content upload bodies. + out.Body = nil + } + beforeBody, beforeLength := out.Body, out.ContentLength + prepareRequestTrailers(clone, out, false) + if out.Body != beforeBody || out.ContentLength != beforeLength || len(out.TransferEncoding) != 0 { + t.Error("trailer preparation altered an unsupported/body-free request") + } + requestTrailerAssertAbsent(t, clone.Header, "Proxy-Authorization") + }) + } +} + +func TestRequestTrailerBodyReadOutcomes(t *testing.T) { + readFailure := errors.New("fictional upload read failure") + closeFailure := errors.New("fictional upload close failure") + for _, test := range []struct { + name string + data string + err error + }{ + {"zero_eof", "", io.EOF}, + {"bytes_eof", "tail", io.EOF}, + {"read_failure", "tail", readFailure}, + {"read_canceled", "tail", context.Canceled}, + } { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "https://public.example/upload", nil) + request.Trailer = http.Header{"X-End": nil} + reads, closes := 0, 0 + request.Body = &requestTrailerUnitBody{ + read: func(p []byte) (int, error) { + reads++ + request.Trailer = http.Header{"X-End": {fmt.Sprintf("terminal-%d", reads)}} + return copy(p, test.data), test.err + }, + close: func() error { closes++; return closeFailure }, + } + out := request.Clone(request.Context()) + prepareRequestTrailers(request, out, false) + buffer := make([]byte, 16) + n, err := out.Body.Read(buffer) + if n != len(test.data) || err != test.err || string(buffer[:n]) != test.data { + t.Fatalf("Read changed n/bytes/error: %d/%q/%v", n, buffer[:n], err) + } + if test.err == io.EOF { + if out.Trailer.Get("X-End") != "terminal-1" { + t.Error("successful EOF did not publish trailers before returning") + } + _, _ = out.Body.Read(buffer) + if out.Trailer.Get("X-End") != "terminal-1" { + t.Error("a repeated Read published trailer values more than once") + } + } else if out.Trailer.Get("X-End") != "" { + t.Error("a non-EOF read failure was presented as completed trailers") + } + for expected := 1; expected <= 2; expected++ { + if err := out.Body.Close(); err != closeFailure || closes != expected { + t.Errorf("Close changed delegate result/count: %v/%d", err, closes) + } + } + }) + } + t.Run("close_without_eof_does_not_publish", func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "https://public.example/upload", nil) + request.Trailer = http.Header{"X-End": nil} + request.Body = &requestTrailerUnitBody{close: func() error { + request.Trailer = http.Header{"X-End": {"close-only-value"}} + return closeFailure + }} + out := request.Clone(request.Context()) + prepareRequestTrailers(request, out, false) + if err := out.Body.Close(); err != closeFailure || out.Trailer.Get("X-End") != "" { + t.Error("Close fabricated successful EOF metadata or changed its error") + } + }) +} + +func TestRequestTrailerBodyConcurrentClose(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "https://public.example/upload", nil) + request.Trailer = http.Header{"X-End": nil} + entered, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + readFailure := errors.New("fictional read interrupted by Close") + closeFailure := errors.New("fictional Close result") + var closes atomic.Int64 + request.Body = &requestTrailerUnitBody{ + read: func([]byte) (int, error) { + close(entered) + <-release + // Only Read updates metadata. Close does not mutate a map while + // Read is in progress: that is outside the supported source contract. + request.Trailer = http.Header{"X-End": {"incomplete-upload"}} + return 0, readFailure + }, + close: func() error { + closes.Add(1) + releaseOnce.Do(func() { close(release) }) + return closeFailure + }, + } + out := request.Clone(request.Context()) + prepareRequestTrailers(request, out, false) + readDone, closeDone := make(chan error, 1), make(chan error, 1) + readJoined, closeJoined := make(chan struct{}), make(chan struct{}) + closeStarted := false + t.Cleanup(func() { + // This independent release also joins a reader if a broken wrapper + // holds a read lock and prevents its own Close from reaching the body. + releaseOnce.Do(func() { close(release) }) + requestTrailerWait(t, readJoined, "blocked body reader cleanup") + if closeStarted { + requestTrailerWait(t, closeJoined, "body closer cleanup") + } + }) + go func() { + defer close(readJoined) + _, err := out.Body.Read(make([]byte, 1)) + readDone <- err + }() + if !requestTrailerWait(t, entered, "body Read entry") { + return + } + closeStarted = true + go func() { defer close(closeJoined); closeDone <- out.Body.Close() }() + select { + case err := <-closeDone: + if err != closeFailure { + t.Errorf("Close returned %v, not its original delegate error", err) + } + case <-time.After(time.Second): + t.Error("Close waited for the blocked reader instead of interrupting it") + return + } + select { + case err := <-readDone: + if err != readFailure { + t.Errorf("interrupted Read error = %v", err) + } + case <-time.After(time.Second): + t.Error("Close did not unblock the body reader") + return + } + if !requestTrailerWait(t, readJoined, "body reader join") || !requestTrailerWait(t, closeJoined, "body closer join") { + return + } + if closes.Load() != 1 || out.Trailer.Get("X-End") != "" || request.Trailer.Get("X-End") != "incomplete-upload" { + t.Error("concurrent Close changed ownership/count or published non-EOF trailers") + } +} + +// This control uses a real verified TLS upstream, not an incoming H2/H3 +// frontend. The combined-listener tests independently cover incoming protocols. +func TestReverseProxyRequestTrailersHTTPSUpstream(t *testing.T) { + for _, constructor := range []string{"default", "public_origin"} { + for _, protocol := range []int{1, 2} { + t.Run(fmt.Sprintf("%s_h%d", constructor, protocol), func(t *testing.T) { + requestTrailerHTTPSExchange(t, constructor, protocol) + }) + } + } +} + +func requestTrailerHTTPSExchange(t *testing.T, constructor string, protocol int) { + t.Helper() + payload := strings.Repeat("owned-checksum-upload;", 64) + sum := sha256.Sum256([]byte(payload)) + digest := "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":" + type observation struct { + proto int + body string + header, trailer http.Header + transfer []string + err error + } + observed := make(chan observation, 1) + var requests, unexpected atomic.Int64 + var workerMu sync.Mutex + var workers sync.WaitGroup + stopping := false + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + workerMu.Lock() + if stopping { + workerMu.Unlock() + return + } + workers.Add(1) + workerMu.Unlock() + defer workers.Done() + requests.Add(1) + body, err := io.ReadAll(r.Body) + _ = r.Body.Close() + result := observation{r.ProtoMajor, string(body), r.Header.Clone(), r.Trailer.Clone(), append([]string(nil), r.TransferEncoding...), err} + select { + case observed <- result: + default: + unexpected.Add(1) + } + if err != nil || string(body) != payload || r.Trailer.Get("Content-Digest") != digest || r.Trailer.Get("X-Upload-End") != "complete" { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = io.WriteString(w, "checksum rejected") + return + } + _, _ = io.WriteString(w, "accepted") + })) + server.EnableHTTP2 = protocol == 2 + server.Config.ReadHeaderTimeout = 2 * time.Second + server.Config.ReadTimeout = 2 * time.Second + server.Config.WriteTimeout = 2 * time.Second + server.Config.IdleTimeout = 2 * time.Second + server.StartTLS() + var transport *http.Transport + cancel := context.CancelFunc(func() {}) + t.Cleanup(func() { + cancel() + if transport != nil { + transport.CloseIdleConnections() + } + workerMu.Lock() + stopping = true + workerMu.Unlock() + serverClosed := make(chan struct{}) + go func() { defer close(serverClosed); server.Close() }() + requestTrailerWait(t, serverClosed, "owned TLS server close") + joined := make(chan struct{}) + go func() { defer close(joined); workers.Wait() }() + requestTrailerWait(t, joined, "owned TLS origin handler join") + if unexpected.Load() != 0 { + t.Errorf("unexpected observer sends = %d", unexpected.Load()) + } + }) + origin, err := url.Parse(server.URL) + if err != nil { + t.Fatal(err) + } + roots := x509.NewCertPool() + roots.AddCert(server.Certificate()) + var verified, badDials atomic.Int64 + transport = &http.Transport{ + Proxy: nil, ForceAttemptHTTP2: protocol == 2, DisableCompression: true, + TLSHandshakeTimeout: time.Second, ResponseHeaderTimeout: 2 * time.Second, + TLSClientConfig: &tls.Config{RootCAs: roots, VerifyConnection: func(state tls.ConnectionState) error { + if len(state.VerifiedChains) == 0 { + return errors.New("owned TLS upstream was not verified") + } + verified.Add(1) + return nil + }}, + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + if network != "tcp" || address != origin.Host { + badDials.Add(1) + return nil, errors.New("nonowned upstream dial rejected") + } + conn, err := (&net.Dialer{Timeout: time.Second}).DialContext(ctx, network, address) + if err == nil { + _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) + } + return conn, err + }, + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + var handler http.Handler + if constructor == "public_origin" { + public, _ := url.Parse("https://public.example") + handler, err = NewReverseProxyHandlerWithPublicOrigin(origin, public, transport) + } else { + handler, err = NewReverseProxyHandler(origin, transport) + } + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "https://public.example/upload", nil).WithContext(ctx) + request.ContentLength = int64(len(payload)) + request.Header.Set("Content-Length", strconv.Itoa(len(payload))) + request.Header.Set("Connection", "X-Hop") + request.Header.Set("Authorization", "fictional-header") + request.Header.Set("Proxy-Authorization", "fictional-header") + request.Header.Set("Forwarded", "host=fictional.example") + request.Trailer = http.Header{ + "Content-Digest": nil, "X-Upload-End": nil, "X-Hop": nil, + "Authorization": nil, "Proxy-Authorization": nil, "Proxy-Authentication-Info": nil, + "Forwarded": nil, "X-Forwarded-For": nil, "X-Real-IP": nil, + } + initial := request.Trailer + beforeHeader, beforeInitial := request.Header.Clone(), initial.Clone() + reader := strings.NewReader(payload) + sourceEOF := make(chan struct{}) + var eofOnce sync.Once + var bodyCloses atomic.Int64 + request.Body = &requestTrailerUnitBody{ + read: func(p []byte) (int, error) { + n, err := reader.Read(p) + if err == io.EOF { + eofOnce.Do(func() { + request.Trailer = http.Header{ + "Content-Digest": {digest}, "X-Upload-End": {"complete"}, "X-Hop": {"fictional-hop"}, + "Authorization": {"fictional-trailer"}, "Proxy-Authorization": {"fictional-trailer"}, + "Proxy-Authentication-Info": {"fictional-trailer"}, "Forwarded": {"host=fictional.example"}, + "X-Forwarded-For": {"192.0.2.1"}, "X-Real-IP": {"192.0.2.2"}, + } + close(sourceEOF) + }) + } + return n, err + }, + close: func() error { bodyCloses.Add(1); return nil }, + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + var got observation + select { + case got = <-observed: + case <-time.After(time.Second): + t.Fatalf("real TLS origin was not reached; frontend status=%d", recorder.Code) + } + if !requestTrailerWait(t, sourceEOF, "owned source body EOF") { + return + } + if got.err != nil || got.body != payload || got.proto != protocol || recorder.Code != http.StatusOK || recorder.Body.String() != "accepted" { + t.Errorf("TLS upstream exchange = proto%d body%t readErr%v status%d response%q", got.proto, got.body == payload, got.err, recorder.Code, recorder.Body.String()) + } + if got.trailer.Get("Content-Digest") != digest || got.trailer.Get("X-Upload-End") != "complete" { + t.Errorf("declared EOF metadata lost: %v", got.trailer) + } + requestTrailerAssertAbsent(t, got.header, "Authorization", "Proxy-Authorization", "Proxy-Authentication-Info", "X-Hop") + requestTrailerAssertAbsent(t, got.trailer, "Authorization", "Proxy-Authorization", "Proxy-Authentication-Info", "X-Hop") + if protocol == 2 { + if len(got.transfer) != 0 || got.header.Get("Transfer-Encoding") != "" { + t.Error("H1 chunked framing leaked onto the actual H2 upstream") + } + } else if !reflect.DeepEqual(got.transfer, []string{"chunked"}) { + t.Errorf("positive incoming length did not select trailer-capable H1 framing: %v", got.transfer) + } + if constructor == "public_origin" { + requestTrailerAssertAbsent(t, got.trailer, "Forwarded", "X-Forwarded-For", "X-Real-IP") + if got.header.Get("Forwarded") != "" || got.header.Get("X-Forwarded-Host") != "public.example" || got.header.Get("X-Forwarded-Proto") != "https" { + t.Error("public origin forwarding header policy changed") + } + } else if got.trailer.Get("Forwarded") != "host=fictional.example" { + t.Error("the opt-in trailer forwarding policy affected the default constructor") + } + if requests.Load() != 1 || unexpected.Load() != 0 || verified.Load() != 1 || badDials.Load() != 0 || bodyCloses.Load() == 0 { + t.Errorf("owned lifecycle controls: requests=%d unexpected=%d verified=%d badDials=%d bodyCloses=%d", requests.Load(), unexpected.Load(), verified.Load(), badDials.Load(), bodyCloses.Load()) + } + if !reflect.DeepEqual(request.Header, beforeHeader) || !reflect.DeepEqual(initial, beforeInitial) || + request.ContentLength != int64(len(payload)) || request.Trailer.Get("Authorization") != "fictional-trailer" || request.Trailer.Get("X-Hop") != "fictional-hop" { + t.Error("proxy altered original initial/terminal request metadata") + } + t.Logf("actual verified TLS upstream H%d; encoded message digest and full %d-byte upload retained", got.proto, len(payload)) +} + +type requestTrailerUnitBody struct { + read func([]byte) (int, error) + close func() error +} + +func (b *requestTrailerUnitBody) Read(p []byte) (int, error) { + if b.read == nil { + return 0, io.EOF + } + return b.read(p) +} + +func (b *requestTrailerUnitBody) Close() error { + if b.close == nil { + return nil + } + return b.close() +} + +func requestTrailerAssertAbsent(t *testing.T, header http.Header, names ...string) { + t.Helper() + for field := range header { + for _, name := range names { + if strings.EqualFold(field, name) { + t.Errorf("unsafe/undeclared trailer or header %s survived", field) + } + } + } +} + +func requestTrailerAssertFraming(t *testing.T, request *http.Request) { + t.Helper() + if request.ContentLength != -1 || !reflect.DeepEqual(request.TransferEncoding, []string{"chunked"}) || + request.Header.Get("Content-Length") != "" || request.GetBody != nil { + t.Error("wrapped request retained a known length or an unbridged replay path") + } +} + +func requestTrailerWait(t *testing.T, joined <-chan struct{}, label string) bool { + t.Helper() + select { + case <-joined: + return true + case <-time.After(2 * time.Second): + t.Errorf("%s did not complete within the fixture budget", label) + return false + } +} diff --git a/internal/tunnel/web_cover_request_trailers_test.go b/internal/tunnel/web_cover_request_trailers_test.go new file mode 100644 index 0000000..9905147 --- /dev/null +++ b/internal/tunnel/web_cover_request_trailers_test.go @@ -0,0 +1,476 @@ +package tunnel + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/apernet/quic-go" + "github.com/apernet/quic-go/http3" + "github.com/cppla/autocar/internal/cover" + "github.com/cppla/autocar/internal/transport" +) + +// The direct positive control is actual HTTP/1.1 to the owned origin, not a +// bare H2/H3 server. The cover exchanges use actual combined ListenWeb H1/H2/H3. +// A complete origin body chunk must arrive before the client may publish its +// late trailers and EOF, so neither prepopulated values nor buffering can stand +// in for request-trailer streaming. +func TestWebCoverRequestTrailersStreamAcrossProtocols(t *testing.T) { + for _, constructor := range []string{"fixed_origin", "public_origin"} { + t.Run(constructor, func(t *testing.T) { + for _, proto := range []int{1, 2, 3} { + t.Run(fmt.Sprintf("h%d", proto), func(t *testing.T) { + framings := []string{"unknown_length"} + if proto != 1 { + // H2/H3 can carry trailers with a positive Content-Length. + // An H1 client cannot: its standard writer drops trailers. + framings = append(framings, "known_length") + } + for _, framing := range framings { + t.Run(framing, func(t *testing.T) { + webCoverRequestTrailersExchange(t, constructor, proto, framing == "known_length", false) + }) + } + }) + } + }) + } +} + +// H1 accepts these declared fields on the wire. The pinned H2/H3 stacks reject +// Authorization and Proxy-Authorization as trailer fields, so those protocols +// are deliberately not given an invalid-request compatibility oracle here. +func TestWebCoverRequestTrailersFilterLateH1Credentials(t *testing.T) { + for _, constructor := range []string{"fixed_origin", "public_origin"} { + t.Run(constructor, func(t *testing.T) { + webCoverRequestTrailersExchange(t, constructor, 1, false, true) + }) + } +} + +type webCoverRequestTrailersObservation struct { + phase string + header, trailer http.Header + body []byte + err error + joined <-chan struct{} +} + +type webCoverRequestTrailersEntry struct { + phase string + proto int + physical any + joined <-chan struct{} +} + +type webCoverRequestTrailersFixture struct { + ctx context.Context + cancel context.CancelFunc + mu sync.Mutex + closed bool + workers sync.WaitGroup + origin *http.Server + originDone chan struct{} + originResult chan error + server *WebServer + serverDone chan struct{} + serverResult chan error + upstream *http.Transport + direct *http.Transport + front http.RoundTripper + chunks chan string + records chan webCoverRequestTrailersObservation + entries chan webCoverRequestTrailersEntry + unexpected atomic.Int64 + invalidDials atomic.Int64 + tunnelDials atomic.Int64 + resolutions atomic.Int64 +} + +func (f *webCoverRequestTrailersFixture) admit() bool { + f.mu.Lock() + defer f.mu.Unlock() + if f.closed { + return false + } + f.workers.Add(1) + return true +} + +func newWebCoverRequestTrailersFixture(t *testing.T, constructor string, proto int, prefix, complete []byte, digest string) (*webCoverRequestTrailersFixture, *url.URL) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + f := &webCoverRequestTrailersFixture{ctx: ctx, cancel: cancel, chunks: make(chan string, 4), records: make(chan webCoverRequestTrailersObservation, 4), entries: make(chan webCoverRequestTrailersEntry, 4)} + // Installed before binding or launching anything; early Fatal cleanup owns + // exactly the successfully published resources and gates worker admission. + t.Cleanup(func() { f.cleanup(t) }) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + target := &url.URL{Scheme: "http", Host: listener.Addr().String()} + f.origin = &http.Server{ReadHeaderTimeout: 2 * time.Second, ReadTimeout: 2 * time.Second, WriteTimeout: 2 * time.Second, IdleTimeout: time.Second, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !f.admit() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + defer f.workers.Done() + joined := make(chan struct{}) + defer close(joined) + defer r.Body.Close() + phase := r.Header.Get("X-Request-Trailers-Phase") + first := make([]byte, len(prefix)) + _, readErr := io.ReadFull(r.Body, first) + if readErr == nil && bytes.Equal(first, prefix) { + select { + case f.chunks <- phase: + default: + f.unexpected.Add(1) + } + } + rest, err := io.ReadAll(r.Body) + if readErr == nil { + readErr = err + } + body := append(first, rest...) + // The body has reached EOF (or failed) before the server trailer map + // is inspected. H3 may replace that map rather than populate it. + observation := webCoverRequestTrailersObservation{phase: phase, header: r.Header.Clone(), trailer: r.Trailer.Clone(), body: body, err: readErr, joined: joined} + select { + case f.records <- observation: + default: + f.unexpected.Add(1) + } + if readErr != nil || r.Method != http.MethodPost || r.RequestURI != "/upload" || r.Host != target.Host || + !bytes.Equal(body, complete) || r.Trailer.Get("Content-Digest") != digest || r.Trailer.Get("X-Upload-End") != "complete-at-eof" { + http.Error(w, "upload trailers missing", http.StatusUnprocessableEntity) + return + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "upload accepted") + })} + f.originDone, f.originResult = make(chan struct{}), make(chan error, 1) + go func() { defer close(f.originDone); f.originResult <- f.origin.Serve(listener) }() + ownedDial := func(ctx context.Context, network, address string) (net.Conn, error) { + if network != "tcp" || address != target.Host { + f.invalidDials.Add(1) + return nil, errors.New("request-trailer fixture forbids unowned upstream") + } + return (&net.Dialer{Timeout: time.Second}).DialContext(ctx, network, address) + } + f.upstream = &http.Transport{Proxy: nil, DialContext: ownedDial, DisableCompression: true, ResponseHeaderTimeout: 2 * time.Second, IdleConnTimeout: time.Second} + f.direct = &http.Transport{Proxy: nil, DialContext: ownedDial, DisableCompression: true, ResponseHeaderTimeout: 2 * time.Second, IdleConnTimeout: time.Second} + public := &url.URL{Scheme: "https", Host: target.Host} + var website http.Handler + if constructor == "public_origin" { + website, err = cover.NewReverseProxyHandlerWithPublicOrigin(target, public, f.upstream) + } else { + website, err = cover.NewReverseProxyHandler(target, f.upstream) + } + if err != nil { + t.Fatal(err) + } + observingCover := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !f.admit() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + defer f.workers.Done() + joined := make(chan struct{}) + defer close(joined) + var physical any + if r.ProtoMajor == 3 { + if conn, _ := r.Context().Value(webH3ConnectionContextKey{}).(*quic.Conn); conn != nil { + physical = conn + } + } else { + if conn, _ := r.Context().Value(webTLSConnectionContextKey{}).(*tls.Conn); conn != nil { + physical = conn + } + } + select { + case f.entries <- webCoverRequestTrailersEntry{r.Header.Get("X-Request-Trailers-Phase"), r.ProtoMajor, physical, joined}: + default: + f.unexpected.Add(1) + } + website.ServeHTTP(w, r) + }) + serverTLS, clientTLS := testTLSConfigs(t) + f.server, err = ListenWeb(WebServerConfig{TCPAddress: "127.0.0.1:0", UDPAddress: "127.0.0.1:0", Token: webTestToken, TLSConfig: serverTLS, Cover: observingCover, + Dialer: transport.DialFunc(func(context.Context, string, string) (net.Conn, error) { + f.tunnelDials.Add(1) + return nil, errors.New("request-trailer fixture forbids tunnel dial") + }), + UDPResolver: UDPResolverFunc(func(context.Context, string) ([]netip.AddrPort, error) { + f.resolutions.Add(1) + return nil, errors.New("request-trailer fixture forbids UDP resolution") + })}) + if err != nil { + t.Fatal(err) + } + f.serverDone, f.serverResult = make(chan struct{}), make(chan error, 1) + go func() { defer close(f.serverDone); f.serverResult <- f.server.Serve(f.ctx) }() + f.front = webAltSvcPublicTransport(t, clientTLS, proto) + return f, target +} + +func webCoverRequestTrailersExchange(t *testing.T, constructor string, proto int, knownLength, lateCredentials bool) { + t.Helper() + prefix := bytes.Repeat([]byte("p"), 64<<10) // Exceeds the H1 writer buffer. + complete := append(append([]byte(nil), prefix...), []byte("body-end")...) + sum := sha256.Sum256(complete) + digest := "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":" + f, target := newWebCoverRequestTrailersFixture(t, constructor, proto, prefix, complete, digest) + wanted := http.Header{ + "Content-Digest": {digest}, "X-Upload-End": {"complete-at-eof"}, + "Proxy-Authentication-Info": {"fictional-late-proxy-proof"}, + "Forwarded": {"for=fictional-client"}, "X-Forwarded-Host": {"fictional.invalid"}, + "X-Forwarded-Proto": {"http"}, "X-Forwarded-For": {"192.0.2.3"}, + "X-Forwarded-Private-Alias": {"fictional"}, "X-Real-Ip": {"192.0.2.4"}, + } + if lateCredentials { + wanted.Set("Authorization", "fictional-late-authorization") + wanted.Set("Proxy-Authorization", "fictional-late-proxy-authorization") + } + for _, phase := range []string{"direct", "cover"} { + requestContext, cancel := context.WithTimeout(f.ctx, 2*time.Second) + defer cancel() + endpoint, rt, expectedProto := target.String()+"/upload", http.RoundTripper(f.direct), 1 + if phase == "cover" { + endpoint, rt, expectedProto = "https://"+webAltSvcAddress(f.server, proto)+"/upload", f.front, proto + } + request, err := http.NewRequestWithContext(requestContext, http.MethodPost, endpoint, nil) + if err != nil { + t.Fatal(err) + } + request.Host, request.ContentLength = target.Host, -1 + if phase == "cover" && knownLength { + request.ContentLength = int64(len(complete)) + } + request.Header.Set("X-Request-Trailers-Phase", phase) + request.Header.Set("Authorization", "fictional-header-authorization") + request.Header.Set("Proxy-Authorization", "Bearer fictional-invalid-ticket") + request.Header.Set("Origin", "https://"+target.Host) + request.Header.Set("Referer", "https://"+target.Host+"/form") + request.Trailer = make(http.Header, len(wanted)) + for key := range wanted { + request.Trailer[key] = nil + } + body := &webCoverRequestTrailersBody{ctx: requestContext, prefix: prefix, tail: []byte("body-end"), trailer: request.Trailer, wanted: wanted, release: make(chan struct{}), closed: make(chan struct{})} + request.Body = body + // The gate is owned independently of success assertions and is released + // before fixture cleanup closes either side of the real exchange. + t.Cleanup(func() { body.allowEOF(); _ = body.Close() }) + outcome, joined := make(chan webCoverRequestTrailersOutcome, 1), make(chan struct{}) + if !f.admit() { + t.Fatal("request worker rejected by fixture cleanup") + } + go func() { + defer f.workers.Done() + defer close(joined) + response, err := rt.RoundTrip(request) + result := webCoverRequestTrailersOutcome{err: err} + if response != nil { + result.status, result.proto = response.StatusCode, response.ProtoMajor + result.body, result.readErr = io.ReadAll(response.Body) + result.closeErr = response.Body.Close() + } + outcome <- result + }() + select { + case observed := <-f.chunks: + if observed != phase { + t.Fatalf("first-body witness phase=%q, want %q", observed, phase) + } + case <-joined: + t.Fatalf("exchange completed before real origin first chunk: %+v", <-outcome) + case <-requestContext.Done(): + t.Fatal("real origin did not receive a complete first chunk before the request budget") + } + body.allowEOF() + webCoverRequestTrailersRequireJoin(t, joined, "public request") + result := <-outcome + if result.err != nil || result.readErr != nil || result.closeErr != nil || result.status != http.StatusOK || result.proto != expectedProto || string(result.body) != "upload accepted" { + t.Errorf("%s result status/protocol/body=%d/h%d/%q errors=%v/%v/%v; want 200/h%d/upload accepted", phase, result.status, result.proto, result.body, result.err, result.readErr, result.closeErr, expectedProto) + } + var observed webCoverRequestTrailersObservation + select { + case observed = <-f.records: + case <-f.ctx.Done(): + t.Fatal("origin never completed its body/trailer observation") + } + webCoverRequestTrailersRequireJoin(t, observed.joined, "origin handler") + if observed.phase != phase || observed.err != nil || !bytes.Equal(observed.body, complete) { + t.Errorf("%s origin phase/body/read error=%q/%d/%v", phase, observed.phase, len(observed.body), observed.err) + } + if phase == "direct" { + for key, values := range wanted { + if observed.trailer.Get(key) != values[0] { + t.Errorf("direct H1 origin did not receive late %s: %v", key, observed.trailer) + } + } + continue + } + var entry webCoverRequestTrailersEntry + select { + case entry = <-f.entries: + case <-f.ctx.Done(): + t.Fatal("cover handler entry absent") + } + webCoverRequestTrailersRequireJoin(t, entry.joined, "cover handler") + if entry.phase != phase || entry.proto != proto || entry.physical == nil { + t.Errorf("actual cover entry phase/protocol/physical=%q/h%d/%T", entry.phase, entry.proto, entry.physical) + } + for _, key := range []string{"Content-Digest", "X-Upload-End"} { + if observed.trailer.Get(key) != wanted.Get(key) { + t.Errorf("ordinary late %s lost: %v", key, observed.trailer) + } + } + for _, key := range []string{"Authorization", "Proxy-Authorization", "Proxy-Authentication-Info"} { + if len(observed.header.Values(key)) != 0 || len(observed.trailer.Values(key)) != 0 { + t.Errorf("relay credential namespace %s reached origin header/trailer: %v/%v", key, observed.header, observed.trailer) + } + } + if constructor == "public_origin" { + for key := range wanted { + if key == "Forwarded" || key == "X-Real-Ip" || bytes.HasPrefix([]byte(key), []byte("X-Forwarded-")) { + if len(observed.trailer.Values(key)) != 0 { + t.Errorf("late public forwarding field %s reached origin: %v", key, observed.trailer) + } + } + } + if observed.header.Get("X-Forwarded-Host") != target.Host || observed.header.Get("X-Forwarded-Proto") != "https" { + t.Errorf("configured public forwarding headers changed: %v", observed.header) + } + } + } + if f.invalidDials.Load() != 0 || f.tunnelDials.Load() != 0 || f.resolutions.Load() != 0 || f.unexpected.Load() != 0 { + t.Errorf("unowned/tunnel/resolver/unexpected counts=%d/%d/%d/%d", f.invalidDials.Load(), f.tunnelDials.Load(), f.resolutions.Load(), f.unexpected.Load()) + } +} + +type webCoverRequestTrailersOutcome struct { + status, proto int + body []byte + err, readErr, closeErr error +} + +type webCoverRequestTrailersBody struct { + ctx context.Context + prefix, tail []byte + offset int + eof bool + trailer, wanted http.Header + release, closed chan struct{} + releaseOnce, closeOnce sync.Once +} + +func (b *webCoverRequestTrailersBody) Read(p []byte) (int, error) { + if b.offset < len(b.prefix) { + n := copy(p, b.prefix[b.offset:]) + b.offset += n + return n, nil + } + select { + case <-b.closed: + return 0, net.ErrClosed + case <-b.ctx.Done(): + return 0, context.Cause(b.ctx) + case <-b.release: + } + if tailOffset := b.offset - len(b.prefix); tailOffset < len(b.tail) { + n := copy(p, b.tail[tailOffset:]) + b.offset += n + return n, nil + } + // The client transport consumes this map only after the body returns EOF. + if !b.eof { + for key, values := range b.wanted { + b.trailer[key] = append([]string(nil), values...) + } + b.eof = true + } + return 0, io.EOF +} + +func (b *webCoverRequestTrailersBody) allowEOF() { b.releaseOnce.Do(func() { close(b.release) }) } +func (b *webCoverRequestTrailersBody) Close() error { + b.closeOnce.Do(func() { close(b.closed) }) + return nil +} + +func webCoverRequestTrailersWait(t *testing.T, done <-chan struct{}, name string) bool { + t.Helper() + select { + case <-done: + return true + case <-time.After(3 * time.Second): + t.Errorf("did not join %s", name) + return false + } +} + +func webCoverRequestTrailersRequireJoin(t *testing.T, done <-chan struct{}, name string) { + t.Helper() + if !webCoverRequestTrailersWait(t, done, name) { + t.FailNow() + } +} + +func (f *webCoverRequestTrailersFixture) cleanup(t *testing.T) { + t.Helper() + f.mu.Lock() + f.closed = true + f.mu.Unlock() + f.cancel() + if f.front != nil { + switch rt := f.front.(type) { + case *http.Transport: + rt.CloseIdleConnections() + case *http3.Transport: + _ = rt.Close() + } + } + if f.server != nil { + if err := f.server.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + t.Errorf("owned combined Close: %v", err) + } + } + if f.origin != nil { + _ = f.origin.Close() + } + if f.direct != nil { + f.direct.CloseIdleConnections() + } + if f.upstream != nil { + f.upstream.CloseIdleConnections() + } + if f.originDone != nil && webCoverRequestTrailersWait(t, f.originDone, "origin Serve") { + if err := <-f.originResult; err != nil && !errors.Is(err, http.ErrServerClosed) { + t.Errorf("origin Serve: %v", err) + } + } + if f.serverDone != nil && webCoverRequestTrailersWait(t, f.serverDone, "combined Serve") { + if err := <-f.serverResult; err != nil { + t.Errorf("combined Serve: %v", err) + } + } + joined := make(chan struct{}) + go func() { defer close(joined); f.workers.Wait() }() + webCoverRequestTrailersWait(t, joined, "all registered request/handler workers") +} diff --git a/internal/tunnel/web_handler.go b/internal/tunnel/web_handler.go index c102a0f..dc682e5 100644 --- a/internal/tunnel/web_handler.go +++ b/internal/tunnel/web_handler.go @@ -12,6 +12,7 @@ import ( "github.com/apernet/quic-go" "github.com/apernet/quic-go/http3" + "github.com/cppla/autocar/internal/cover" ) // webTunnelHandler serves a real cover origin and only upgrades an @@ -107,9 +108,7 @@ func (h *webTunnelHandler) serveCover(w http.ResponseWriter, r *http.Request) { // Clone before removing the credential so other middleware cannot observe a // mutation of the caller's request. A cover reverse proxy must never forward // a tunnel credential to its upstream. - clone := r.Clone(r.Context()) - clone.Header = r.Header.Clone() - clone.Header.Del("Proxy-Authorization") + clone := cover.CloneRequestForCover(r) h.cover.ServeHTTP(w, clone) }