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
29 changes: 29 additions & 0 deletions docs/WEB_COVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,12 @@ The static handler serves `GET` and `HEAD`. Other methods receive the same
ordinary `405 Method Not Allowed` behavior whether they came from a random web
client or from an invalid tunnel probe.

Source builds after v1.0.1 send `OPTIONS *` through the configured cover handler
on HTTP/1.1, HTTP/2, and HTTP/3, instead of letting the TCP server return a
separate automatic response. Static cover therefore returns its ordinary `405`
with `Allow: GET, HEAD`; the combined listener applies its normal bound-port
`Alt-Svc` policy to this response too.

### Fixed upstream origin

```bash
Expand All @@ -174,12 +180,35 @@ the upstream cannot be reached. Consequently, an upstream that requires an
`Authorization` request header is not suitable without a separate authorized
front end.

For an actual `OPTIONS *` request, source builds after v1.0.1 preserve the
asterisk request-target at the fixed upstream authority. The configured base
path and query are not added: this request concerns the origin as a whole, not
a resource below that base path. Resource requests such as `OPTIONS /*`, an
encoded asterisk in a path, or an asterisk in a query keep the usual configured
path/query joining. Visitor-controlled authority still cannot select another
upstream, and `Origin`/`Referer` are not rewritten to bypass website policy.

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.

Source builds after v1.0.1 retain the original response's `Connection`
nominations when filtering trailer declarations and fields discovered later at
EOF or Close, including replacement trailer maps. A connection-specific field
cannot reappear just because its declaration was removed before the body ended.
This depends on the upstream transport exposing those nominations; the native
parser's `Connection: close` limitation described above is unchanged.

The same source builds remove the relay-owned `Proxy-Authentication-Info`
namespace from cover request headers and upstream final, informational,
trailer, and validated WebSocket responses. This prevents an upstream's metadata
from being presented as relay authentication metadata; it is not a new proof mechanism.
Ordinary website `Authentication-Info` and `WWW-Authenticate` fields are retained
unless nominated as connection-specific. Authenticated tunnel proofs are not
subject to this cover-only filter.

Source builds also apply that filter to upstream informational responses,
including `103 Early Hints`, before forwarding them. Ordinary `Link` hints and
other end-to-end fields remain available; credentials, hop-by-hop fields and
Expand Down
124 changes: 95 additions & 29 deletions internal/cover/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ func NewReverseProxyHandler(origin *url.URL, transport http.RoundTripper) (http.
upgrade := websocketRequestEligibility(request.In)
request.SetURL(target)
request.Out.Host = target.Host
if isOptionsAsterisk(request.In) {
// A server-wide OPTIONS target is not a resource below the
// origin's configured base path or query. Keep only its fixed
// upstream scheme and authority from SetURL.
request.Out.URL.Path = "*"
request.Out.URL.RawPath = ""
request.Out.URL.RawQuery = ""
request.Out.URL.ForceQuery = false
}
// ReverseProxy already removes nominated fields before Rewrite,
// then restores its generic Upgrade pair. Retain the original
// nomination boundary, and restore only our validated H1 WebSocket.
Expand Down Expand Up @@ -112,12 +121,11 @@ func NewReverseProxyHandler(origin *url.URL, transport http.RoundTripper) (http.
response.Header.Set("Upgrade", "websocket")
return nil // Preserve duplex I/O and optional CloseWrite.
}
// Production responses were prepared by the transport before
// ReverseProxy removed their original Connection fields. Keep the
// direct hook useful too, without replacing an already saved policy.
prepareResponseTrailers(response)
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, request *http.Request, _ error) {
Expand Down Expand Up @@ -152,21 +160,48 @@ func (t *informationalHeaderTransport) RoundTrip(request *http.Request) (*http.R
response, err := t.base.RoundTrip(request)
if response != nil && response.StatusCode == http.StatusSwitchingProtocols {
response.Request = trusted
} else if response != nil && err == nil {
// ReverseProxy removes response hop fields before ModifyResponse.
// Capture their nominations here so late trailers cannot revive them.
prepareResponseTrailers(response)
}
return response, err
}

func isOptionsAsterisk(request *http.Request) bool {
return request != nil && request.URL != nil && request.Method == http.MethodOptions &&
request.RequestURI == "*" && request.URL.Path == "*" && request.URL.RawPath == "" &&
request.URL.Opaque == "" && request.URL.RawQuery == "" && !request.URL.ForceQuery &&
request.URL.Fragment == "" && request.URL.RawFragment == "" && request.URL.User == nil
}

// Save a request-local, immutable union of initial response Header and Trailer
// nominations before either map is scrubbed. A current Trailer map may add new
// nominations later; it must never change this original policy. A 101 body is
// duplex and is deliberately prepared only by the separate WebSocket owner.
func prepareResponseTrailers(response *http.Response) {
if body, ok := response.Body.(*responseTrailerBody); ok && body.response == response {
return
}
nominations := collectConnectionNominations(response.Header, response.Trailer)
removeUnsafeHeadersWithNominations(response.Trailer, nominations)
if response.Body != nil {
response.Body = &responseTrailerBody{body: response.Body, response: response, nominations: nominations}
}
}

// 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
body io.ReadCloser
response *http.Response
nominations connectionNominations
mu sync.Mutex
active int
pending bool
}

func (b *responseTrailerBody) Read(p []byte) (int, error) {
Expand Down Expand Up @@ -195,7 +230,8 @@ func (b *responseTrailerBody) finishOperation(terminal bool) {
b.active--
b.pending = b.pending || terminal
if b.active == 0 && b.pending {
removeUnsafeHeaders(b.response.Trailer)
current := collectConnectionNominations(b.response.Trailer)
removeUnsafeHeadersWithNominations(b.response.Trailer, b.nominations, current)
b.pending = false
}
}
Expand All @@ -222,46 +258,76 @@ func normalizeOrigin(origin *url.URL) (*url.URL, error) {
}

func removeUnsafeHeaders(header http.Header) {
removeConnectionNominatedHeaders(header, header)
removeUnsafeHeadersWithNominations(header, collectConnectionNominations(header))
}

func removeUnsafeHeadersWithNominations(header http.Header, nominations ...connectionNominations) {
applyConnectionNominations(header, nominations...)
for _, name := range hopByHopHeaders {
deleteHeaderFold(header, name)
}
deleteHeaderFold(header, "Authorization")
// This namespace belongs to relay-authenticated responses, not the public
// cover origin. Authentication-Info and WWW-Authenticate remain end-to-end.
deleteHeaderFold(header, "Proxy-Authentication-Info")
}

func removeConnectionNominatedHeaders(header, connectionSource http.Header) {
var nominations map[string]struct{}
applyConnectionNominations(header, collectConnectionNominations(connectionSource))
}

// Construct once, then only read this set while applying it to Header or any
// subsequent Trailer map. Keys own their bytes independently of source maps.
type connectionNominations map[string]struct{}

func collectConnectionNominations(sources ...http.Header) connectionNominations {
var nominations connectionNominations
var scratch [64]byte
folded := scratch[:0]
for _, value := range headerValuesFold(connectionSource, "Connection") {
for token := range strings.SplitSeq(value, ",") {
name := strings.TrimSpace(token)
if !httpToken(name) {
continue
}
folded = foldASCIIHeaderName(folded, name)
if _, exists := nominations[string(folded)]; !exists {
if nominations == nil {
nominations = make(map[string]struct{})
for _, source := range sources {
for _, value := range headerValuesFold(source, "Connection") {
for token := range strings.SplitSeq(value, ",") {
name := strings.TrimSpace(token)
if !httpToken(name) {
continue
}
folded = foldASCIIHeaderName(folded, name)
if _, exists := nominations[string(folded)]; !exists {
if nominations == nil {
nominations = make(connectionNominations)
}
// Only a new nomination owns a copied key. Repeated tokens
// reuse scratch; they never rescan the destination header.
nominations[string(folded)] = struct{}{}
}
// Only a new nomination owns a copied key. Repeated tokens
// reuse scratch; they never rescan the destination header.
nominations[string(folded)] = struct{}{}
}
}
}
if len(nominations) == 0 {
return nominations
}

func applyConnectionNominations(header http.Header, nominations ...connectionNominations) {
active := false
for _, set := range nominations {
active = active || len(set) != 0
}
if !active {
return
}
// Collect first: header and connectionSource may be the same map, and
// Connection itself may be nominated without hiding later nominations.
var scratch [64]byte
folded := scratch[:0]
for field := range header {
if !httpToken(field) {
continue
}
folded = foldASCIIHeaderName(folded, field)
if _, nominated := nominations[string(folded)]; nominated {
delete(header, field)
for _, set := range nominations {
if _, nominated := set[string(folded)]; nominated {
delete(header, field)
break
}
}
}
}
Expand Down
Loading
Loading