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
21 changes: 21 additions & 0 deletions docs/WEB_COVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 1 addition & 4 deletions internal/cover/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
136 changes: 136 additions & 0 deletions internal/cover/request_trailer.go
Original file line number Diff line number Diff line change
@@ -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() }
Loading
Loading