diff --git a/docs/WEB_COVER.md b/docs/WEB_COVER.md index 15c90c6..60705c2 100644 --- a/docs/WEB_COVER.md +++ b/docs/WEB_COVER.md @@ -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 @@ -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 diff --git a/internal/cover/handler.go b/internal/cover/handler.go index a2d2a4b..38d5379 100644 --- a/internal/cover/handler.go +++ b/internal/cover/handler.go @@ -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. @@ -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) { @@ -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) { @@ -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 } } @@ -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 + } } } } diff --git a/internal/cover/metadata_policy_test.go b/internal/cover/metadata_policy_test.go new file mode 100644 index 0000000..35e49ef --- /dev/null +++ b/internal/cover/metadata_policy_test.go @@ -0,0 +1,437 @@ +package cover + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/http/httptrace" + "net/http/httputil" + "net/textproto" + "net/url" + "strings" + "testing" +) + +const metadataPolicyProofHeader = "Proxy-Authentication-Info" + +func metadataPolicyHeaders() http.Header { + header := make(http.Header) + header.Set(metadataPolicyProofHeader, "nextnonce=fictional-origin-proof") + header["proxy-authentication-info"] = []string{"nextnonce=fictional-case-alias"} + header.Set("Authentication-Info", "nextnonce=ordinary-website-auth") + header.Set("WWW-Authenticate", "Basic realm=ordinary-website") + header.Set("X-End", "retained") + return header +} + +func metadataPolicyAssertProofAbsent(t *testing.T, header http.Header) { + t.Helper() + if values := headerValuesFold(header, metadataPolicyProofHeader); len(values) != 0 { + t.Errorf("relay-owned proof namespace survived: %v", values) + } +} + +func metadataPolicyAssertEndToEnd(t *testing.T, header http.Header) { + t.Helper() + for name, want := range map[string]string{ + "Authentication-Info": "nextnonce=ordinary-website-auth", + "WWW-Authenticate": "Basic realm=ordinary-website", + "X-End": "retained", + } { + if got := header.Get(name); got != want { + t.Errorf("ordinary %s = %q, want %q", name, got, want) + } + } +} + +func metadataPolicyProxy(t *testing.T, transport http.RoundTripper) *httputil.ReverseProxy { + t.Helper() + handler, err := NewReverseProxyHandler(&url.URL{Scheme: "https", Host: "fixed-origin.invalid:8443", Path: "/base", RawQuery: "operator=one"}, transport) + if err != nil { + t.Fatal(err) + } + proxy, ok := handler.(*httputil.ReverseProxy) + if !ok { + t.Fatalf("handler = %T, want native ReverseProxy", handler) + } + return proxy +} + +func metadataPolicyPrepareResponse(t *testing.T, response *http.Response) { + t.Helper() + request := httptest.NewRequest(http.MethodGet, "https://visitor.invalid/ordinary", nil) + response.Request = request + proxy := metadataPolicyProxy(t, roundTripFunc(func(*http.Request) (*http.Response, error) { + return response, nil + })) + got, err := proxy.Transport.RoundTrip(request) + if err != nil || got != response || got.Request != request { + t.Fatalf("ordinary response/Request identity changed: %p/%v", got, err) + } + // The original nominations must be captured by the transport boundary, + // before ReverseProxy destructively filters the response Header map. + if err := proxy.ModifyResponse(got); err != nil { + t.Fatal(err) + } +} + +func TestCoverMetadataPolicyRelayProofNamespace(t *testing.T) { + t.Run("helper_ascii_aliases_and_end_to_end_controls", func(t *testing.T) { + header := metadataPolicyHeaders() + header.Set("Connection", " X-Private, X-Key, bad(token, X-Private ") + header.Set("X-Private", "remove") + header.Set("X-Key", "not nominated by an invalid Unicode token") + removeUnsafeHeaders(header) + metadataPolicyAssertProofAbsent(t, header) + metadataPolicyAssertEndToEnd(t, header) + if header.Get("Connection") != "" || header.Get("X-Private") != "" || header.Get("X-Key") == "" { + t.Errorf("valid/invalid nomination boundary changed: %v", header) + } + }) + t.Run("nominated_ordinary_auth_fields_still_removed", func(t *testing.T) { + header := metadataPolicyHeaders() + header.Set("Connection", "Authentication-Info, WWW-Authenticate") + removeUnsafeHeaders(header) + metadataPolicyAssertProofAbsent(t, header) + if header.Get("Authentication-Info") != "" || header.Get("WWW-Authenticate") != "" || header.Get("X-End") != "retained" { + t.Errorf("ordinary auth fields did not retain nomination semantics: %v", header) + } + }) + t.Run("rewrite_cannot_forward_relay_proof", func(t *testing.T) { + proxy := metadataPolicyProxy(t, nil) + request := httptest.NewRequest(http.MethodPost, "https://visitor.invalid/form?q=two", strings.NewReader("ordinary body")) + request.Header = metadataPolicyHeaders() + request.Header.Set("Origin", "https://visitor.invalid") + request.Header.Set("Referer", "https://visitor.invalid/form") + request.Header.Set("Cookie", "ordinary=retained") + body := request.Body + pr := &httputil.ProxyRequest{In: request, Out: request.Clone(request.Context())} + proxy.Rewrite(pr) + metadataPolicyAssertProofAbsent(t, pr.Out.Header) + metadataPolicyAssertEndToEnd(t, pr.Out.Header) + if pr.Out.Body != body || pr.Out.URL.Path != "/base/form" || pr.Out.URL.RawQuery != "operator=one&q=two" || pr.Out.Host != "fixed-origin.invalid:8443" { + t.Errorf("ordinary fixed-origin/body behavior changed: %s / %s", pr.Out.URL, pr.Out.Host) + } + for name, want := range map[string]string{"Origin": "https://visitor.invalid", "Referer": "https://visitor.invalid/form", "Cookie": "ordinary=retained"} { + if got := pr.Out.Header.Get(name); got != want { + t.Errorf("ordinary %s changed: %q", name, got) + } + } + // Rewrite must not mutate caller-owned headers. + if len(headerValuesFold(request.Header, metadataPolicyProofHeader)) != 2 { + t.Error("Rewrite mutated the original request metadata") + } + }) + t.Run("final_and_initial_trailer", func(t *testing.T) { + response := &http.Response{StatusCode: http.StatusOK, Header: metadataPolicyHeaders(), Trailer: metadataPolicyHeaders(), Body: http.NoBody} + modifyTrailerResponse(t, response) + metadataPolicyAssertProofAbsent(t, response.Header) + metadataPolicyAssertProofAbsent(t, response.Trailer) + metadataPolicyAssertEndToEnd(t, response.Header) + metadataPolicyAssertEndToEnd(t, response.Trailer) + _ = response.Body.Close() + }) +} + +func TestCoverMetadataPolicyInitialNominationsPersist(t *testing.T) { + t.Run("initial_header_and_trailer_declarations", func(t *testing.T) { + response := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Connection": {"X-From-Header"}}, + Trailer: http.Header{ + "Connection": {"X-From-Trailer"}, "X-From-Header": nil, + "X-From-Trailer": nil, "X-End": nil, + }, + Body: http.NoBody, + } + metadataPolicyPrepareResponse(t, response) + for _, name := range []string{"Connection", "X-From-Header", "X-From-Trailer"} { + if len(headerValuesFold(response.Trailer, name)) != 0 || metadataPolicyHasField(response.Trailer, name) { + t.Errorf("unsafe initial trailer declaration %s survived: %v", name, response.Trailer) + } + } + if _, ok := response.Trailer["X-End"]; !ok { + t.Error("ordinary initial trailer declaration was removed") + } + _ = response.Body.Close() + }) + readFailure := errors.New("original terminal read failure") + for _, terminal := range []struct { + name string + err error + }{{"eof", io.EOF}, {"read_error", readFailure}, {"cancellation", context.Canceled}} { + for _, replacement := range []bool{false, true} { + name := terminal.name + "/mutation" + if replacement { + name = terminal.name + "/replacement" + } + t.Run(name, func(t *testing.T) { + response := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Connection": {" X-From-Header, X-From-Header, X-Key, bad(token "}}, + Trailer: http.Header{"Connection": {"X-From-Trailer"}, "X-End": nil}, + } + reads, closes := 0, 0 + response.Body = &trailerTestBody{read: func(p []byte) (int, error) { + reads++ + if reads == 1 { + return copy(p, "first"), nil + } + late := metadataPolicyLateTrailers() + if replacement { + response.Trailer = late + } else { + for field, values := range late { + response.Trailer[field] = values + } + } + return copy(p, "last"), terminal.err + }, close: func() error { closes++; return nil }} + metadataPolicyPrepareResponse(t, response) + if reads != 0 || closes != 0 { + t.Fatal("ModifyResponse consumed or closed the streaming body") + } + // A saved policy must not alias the destructively filtered Header + // or consult a later replacement of it. + response.Header = http.Header{"Connection": {"X-End, Authentication-Info, WWW-Authenticate"}} + buffer := make([]byte, 16) + n, err := response.Body.Read(buffer) + if n != 5 || err != nil || string(buffer[:n]) != "first" || reads != 1 { + t.Fatalf("first streaming read = %d/%v/%q, calls=%d", n, err, buffer[:n], reads) + } + n, err = response.Body.Read(buffer) + if n != 4 || err != terminal.err || string(buffer[:n]) != "last" || reads != 2 { + t.Fatalf("terminal read changed original n/error identity: %d/%v/%q", n, err, buffer[:n]) + } + metadataPolicyAssertLateTrailers(t, response.Trailer) + if err := response.Body.Close(); err != nil || closes != 1 { + t.Errorf("Close delegation = %v/%d", err, closes) + } + }) + } + } + t.Run("close_replacement_and_original_error_identity", func(t *testing.T) { + closeFailure := errors.New("original close failure") + response := &http.Response{ + StatusCode: http.StatusOK, Header: http.Header{"Connection": {"X-From-Header"}}, + Trailer: http.Header{"Connection": {"X-From-Trailer"}}, + } + closes := 0 + response.Body = &trailerTestBody{close: func() error { + closes++ + response.Trailer = metadataPolicyLateTrailers() + return closeFailure + }} + metadataPolicyPrepareResponse(t, response) + response.Header.Set("Connection", "X-End") + for want := 1; want <= 2; want++ { + if err := response.Body.Close(); err != closeFailure || closes != want { + t.Fatalf("Close changed original error/call count: %v/%d", err, closes) + } + metadataPolicyAssertLateTrailers(t, response.Trailer) + } + }) +} + +func TestCoverMetadataPolicyInitialNominationsBeforeReverseProxyScrub(t *testing.T) { + for _, replacement := range []bool{false, true} { + name := "announced_mutation" + if replacement { + name = "unannounced_replacement" + } + t.Run(name, func(t *testing.T) { + response := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Connection": {"X-From-Header"}}, + Trailer: http.Header{"X-End": nil}, + } + if !replacement { + response.Trailer["X-From-Header"] = nil + } + reads, closes := 0, 0 + response.Body = &trailerTestBody{read: func(p []byte) (int, error) { + reads++ + if reads == 1 { + return copy(p, "ordinary website body"), nil + } + if replacement { + response.Trailer = make(http.Header) + } + response.Trailer.Set("X-From-Header", "fictional late nominated value") + response.Trailer.Set("X-End", "retained") + return 0, io.EOF + }, close: func() error { closes++; return nil }} + proxy := metadataPolicyProxy(t, roundTripFunc(func(*http.Request) (*http.Response, error) { + return response, nil + })) + writer := httptest.NewRecorder() + proxy.ServeHTTP(writer, httptest.NewRequest(http.MethodGet, "https://visitor.invalid/ordinary", nil)) + result := writer.Result() + defer result.Body.Close() + body, err := io.ReadAll(result.Body) + if err != nil || result.StatusCode != http.StatusOK || string(body) != "ordinary website body" || reads != 2 || closes != 1 { + t.Fatalf("public response/status/body/read/close = %d/%q/%v/%d/%d", result.StatusCode, body, err, reads, closes) + } + if metadataPolicyHasField(result.Trailer, "X-From-Header") || metadataPolicyHasField(response.Trailer, "X-From-Header") { + t.Errorf("original header nomination survived std hop scrub then EOF: public=%v upstream=%v", result.Trailer, response.Trailer) + } + if result.Trailer.Get("X-End") != "retained" { + t.Errorf("ordinary public trailer was lost: %v", result.Trailer) + } + }) + } +} + +func metadataPolicyHasField(header http.Header, name string) bool { + for field := range header { + if httpToken(field) && strings.EqualFold(field, name) { + return true + } + } + return false +} + +func metadataPolicyLateTrailers() http.Header { + header := make(http.Header) + header.Set("X-From-Header", "fictional late header nomination") + header["x-from-header"] = []string{"fictional late ASCII alias"} + header.Set("X-From-Trailer", "fictional late initial trailer nomination") + header.Set("Connection", "X-New-Hop") + header.Set("X-New-Hop", "fictional terminal nomination") + header.Set("X-Key", "invalid Unicode nomination must not remove this") + header.Set("Authentication-Info", "nextnonce=ordinary-website-auth") + header.Set("WWW-Authenticate", "Basic realm=ordinary-website") + header.Set("X-End", "retained") + return header +} + +func metadataPolicyAssertLateTrailers(t *testing.T, header http.Header) { + t.Helper() + for _, name := range []string{"Connection", "X-From-Header", "X-From-Trailer", "X-New-Hop"} { + if metadataPolicyHasField(header, name) { + t.Errorf("initial/terminal nomination %s survived late trailers: %v", name, header) + } + } + metadataPolicyAssertEndToEnd(t, header) + if header.Get("X-Key") == "" { + t.Error("invalid Unicode nomination removed an ordinary ASCII field") + } +} + +func TestCoverMetadataPolicyOptionsAsteriskRewrite(t *testing.T) { + for _, test := range []struct { + name string + change func(*http.Request) + star bool + }{ + {"h1_authority_absent_on_url", func(*http.Request) {}, true}, + {"h2_public_authority_on_url", func(r *http.Request) { r.ProtoMajor = 2; r.URL.Scheme = "https"; r.URL.Host = "visitor.invalid" }, true}, + {"h3_public_authority_on_url", func(r *http.Request) { r.ProtoMajor = 3; r.URL.Scheme = "https"; r.URL.Host = "visitor.invalid" }, true}, + {"get_is_not_options", func(r *http.Request) { r.Method = http.MethodGet }, false}, + {"post_is_not_options", func(r *http.Request) { r.Method = http.MethodPost }, false}, + {"different_request_uri", func(r *http.Request) { r.RequestURI = "/ordinary" }, false}, + {"different_path", func(r *http.Request) { r.URL.Path = "/ordinary" }, false}, + {"encoded_raw_path", func(r *http.Request) { r.URL.RawPath = "%2A" }, false}, + {"opaque_url", func(r *http.Request) { r.URL.Opaque = "*" }, false}, + {"query", func(r *http.Request) { r.URL.RawQuery = "client=two" }, false}, + {"empty_explicit_query", func(r *http.Request) { r.URL.ForceQuery = true }, false}, + {"fragment", func(r *http.Request) { r.URL.Fragment = "ordinary" }, false}, + } { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodOptions, "https://visitor.invalid/ordinary", nil) + request.URL = &url.URL{Path: "*"} + request.RequestURI = "*" + test.change(request) + original := request.URL.String() + proxy := metadataPolicyProxy(t, nil) + pr := &httputil.ProxyRequest{In: request, Out: request.Clone(request.Context())} + proxy.Rewrite(pr) + if pr.Out.URL.Scheme != "https" || pr.Out.URL.Host != "fixed-origin.invalid:8443" || pr.Out.Host != "fixed-origin.invalid:8443" { + t.Errorf("fixed origin authority changed: %s / %s", pr.Out.URL, pr.Out.Host) + } + if test.star { + if pr.Out.URL.Path != "*" || pr.Out.URL.RawPath != "" || pr.Out.URL.Opaque != "" || pr.Out.URL.RawQuery != "" || pr.Out.URL.ForceQuery || pr.Out.URL.Fragment != "" || pr.Out.URL.RequestURI() != "*" { + t.Errorf("server-wide OPTIONS inherited base/query or lost star: %+v, RequestURI=%q", pr.Out.URL, pr.Out.URL.RequestURI()) + } + } else { + // All lookalikes keep the prior normal SetURL policy. This + // comparison does not promise their acceptance by an HTTP parser. + ordinary := &httputil.ProxyRequest{In: request, Out: request.Clone(request.Context())} + ordinary.SetURL(&url.URL{Scheme: "https", Host: "fixed-origin.invalid:8443", Path: "/base", RawQuery: "operator=one"}) + if pr.Out.URL.String() != ordinary.Out.URL.String() { + t.Errorf("lookalike unexpectedly received star policy: %s, want %s", pr.Out.URL, ordinary.Out.URL) + } + } + if request.URL.String() != original || request.RequestURI != pr.Out.RequestURI { + t.Error("Rewrite mutated caller URL or unrelated request metadata") + } + }) + } +} + +func TestCoverMetadataPolicyInformationalProofNamespace(t *testing.T) { + var observed http.Header + caller := &httptrace.ClientTrace{Got1xxResponse: func(_ int, header textproto.MIMEHeader) error { + observed = http.Header(header).Clone() + return nil + }} + proxy := metadataPolicyProxy(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { + trace := httptrace.ContextClientTrace(request.Context()) + if trace == nil || trace.Got1xxResponse == nil { + t.Fatal("transport did not receive the composed informational hook") + } + if err := trace.Got1xxResponse(http.StatusEarlyHints, textproto.MIMEHeader(metadataPolicyHeaders())); err != nil { + t.Fatal(err) + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: http.NoBody}, nil + })) + request := httptest.NewRequest(http.MethodGet, "https://visitor.invalid/", nil) + request = request.WithContext(httptrace.WithClientTrace(request.Context(), caller)) + response, err := proxy.Transport.RoundTrip(request) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if observed == nil { + t.Fatal("existing informational observer did not run") + } + metadataPolicyAssertProofAbsent(t, observed) + metadataPolicyAssertEndToEnd(t, observed) +} + +func TestCoverMetadataPolicyWebsocketProofNamespace(t *testing.T) { + var response *http.Response + proxy := metadataPolicyProxy(t, roundTripFunc(func(*http.Request) (*http.Response, error) { + return response, nil + })) + request := websocketUnitRequest() + pr := &httputil.ProxyRequest{In: request, Out: request.Clone(request.Context())} + proxy.Rewrite(pr) + body := &websocketUnitDuplex{} + response = websocketUnitResponse(nil, body) + for field, values := range metadataPolicyHeaders() { + response.Header[field] = values + } + got, err := proxy.Transport.RoundTrip(pr.Out) + if err != nil || got != response { + t.Fatalf("valid101 transport result = %p/%v", got, err) + } + if err := proxy.ModifyResponse(response); err != nil { + t.Fatal(err) + } + defer response.Body.Close() + metadataPolicyAssertProofAbsent(t, response.Header) + metadataPolicyAssertEndToEnd(t, response.Header) + duplex, ok := response.Body.(io.ReadWriteCloser) + if !ok { + t.Fatal("valid WebSocket body lost duplex I/O") + } + if n, err := duplex.Write([]byte("ordinary")); err != nil || n != 8 || body.writes.Load() != 8 { + t.Errorf("valid WebSocket write delegation changed: %d/%v", n, err) + } + if response.Header.Get("Connection") != "Upgrade" || response.Header.Get("Upgrade") != "websocket" { + t.Error("valid WebSocket normalized pair was lost") + } +} diff --git a/internal/tunnel/web_cover_metadata_test.go b/internal/tunnel/web_cover_metadata_test.go new file mode 100644 index 0000000..0832998 --- /dev/null +++ b/internal/tunnel/web_cover_metadata_test.go @@ -0,0 +1,417 @@ +package tunnel + +import ( + "bufio" + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptrace" + "net/netip" + "net/textproto" + "net/url" + "strings" + "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" +) + +func TestWebCoverMetadataConnectionNominatedTrailerOnWire(t *testing.T) { + for _, proto := range []int{1, 2, 3} { + t.Run(fmt.Sprintf("h%d", proto), func(t *testing.T) { + webCoverMetadataExchange(t, proto, false, false) + }) + } +} + +func TestWebCoverMetadataProxyAuthenticationInfoOnWire(t *testing.T) { + for _, proto := range []int{1, 2, 3} { + t.Run(fmt.Sprintf("h%d", proto), func(t *testing.T) { + for _, phase := range []string{"final", "trailer"} { + t.Run(phase, func(t *testing.T) { + webCoverMetadataExchange(t, proto, true, phase == "trailer") + }) + } + }) + } +} + +const ( + webCoverMetadataEarly = "early|" + webCoverMetadataLate = "complete-payload" + webCoverMetadataWWW = `Basic realm="ordinary-site"` +) + +type webCoverMetadataHint struct { + code int + header http.Header +} + +func webCoverMetadataExchange(t *testing.T, proto int, authenticationInfo, authTrailer bool) { + t.Helper() + origin := webCoverMetadataRawOrigin(t, authenticationInfo, authTrailer) + // Also release the independent origin gate on Fatal, before server cleanup. + defer origin.ungate() + upstream := &http.Transport{ + Proxy: nil, + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + if network != "tcp" || address != origin.address { + return nil, errors.New("metadata fixture forbids every non-owned origin") + } + return (&net.Dialer{Timeout: time.Second}).DialContext(ctx, network, address) + }, + ResponseHeaderTimeout: 2 * time.Second, IdleConnTimeout: time.Second, + } + t.Cleanup(upstream.CloseIdleConnections) + target, err := url.Parse("http://" + origin.address + "/base?operator=1") + if err != nil { + t.Fatal(err) + } + proxy, err := cover.NewReverseProxyHandler(target, upstream) + if err != nil { + t.Fatal(err) + } + coverJoined := make(chan struct{}) + var coverStarted atomic.Bool + website := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + coverStarted.Store(true) + defer close(coverJoined) + proxy.ServeHTTP(w, r) + }) + serverTLS, clientTLS := testTLSConfigs(t) + var targetDials, targetResolutions atomic.Int64 + server, err := ListenWeb(WebServerConfig{ + TCPAddress: "127.0.0.1:0", UDPAddress: "127.0.0.1:0", + Token: webTestToken, TLSConfig: serverTLS, Cover: website, + Dialer: transport.DialFunc(func(context.Context, string, string) (net.Conn, error) { + targetDials.Add(1) + return nil, errors.New("public metadata fixture forbids tunnel destinations") + }), + UDPResolver: UDPResolverFunc(func(context.Context, string) ([]netip.AddrPort, error) { + targetResolutions.Add(1) + return nil, errors.New("public metadata fixture forbids UDP target resolution") + }), + }) + if err != nil { + t.Fatal(err) + } + serveCtx, cancelServe := context.WithCancel(context.Background()) + serveResult, serveJoined := make(chan error, 1), make(chan struct{}) + go func() { defer close(serveJoined); serveResult <- server.Serve(serveCtx) }() + t.Cleanup(func() { + cancelServe() + _ = server.Close() + webCoverMetadataJoin(t, "combined Serve", serveJoined) + if coverStarted.Load() { + webCoverMetadataJoin(t, "cover handler", coverJoined) + } + select { + case err := <-serveResult: + if err != nil { + t.Errorf("combined Serve: %v", err) + } + default: + } + }) + rt := webCoverMetadataPublicTransport(t, clientTLS, proto) + address := server.TCPAddr().String() + if proto == 3 { + address = server.UDPAddr().String() + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + var hintsMu sync.Mutex + var hints []webCoverMetadataHint + trace := &httptrace.ClientTrace{Got1xxResponse: func(code int, header textproto.MIMEHeader) error { + hintsMu.Lock() + defer hintsMu.Unlock() + hints = append(hints, webCoverMetadataHint{code, http.Header(header).Clone()}) + return nil + }} + request, err := http.NewRequestWithContext(httptrace.WithClientTrace(ctx, trace), http.MethodGet, "https://"+address+"/metadata?visitor=2", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Basic fictional-origin-credential") + request.Header.Set("Proxy-Authorization", "Bearer fictional-invalid-ticket") + if authenticationInfo { + request.Header.Set("Proxy-Authentication-Info", `nextnonce="fictional-request-proxy"`) + request.Header.Set("Authentication-Info", `nextnonce="ordinary-request"`) + } + response, err := rt.RoundTrip(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.ProtoMajor != proto || response.TLS == nil || response.StatusCode != http.StatusOK { + t.Fatalf("actual public response = %s TLS=%t status=%d, want HTTP/%d TLS/200", response.Proto, response.TLS != nil, response.StatusCode, proto) + } + if response.Header.Get("X-Site") != "ordinary" || (!authenticationInfo && response.Header.Get(webAuthResponseHeader) != "") || response.Header.Get("Proxy-Authenticate") != "" { + t.Errorf("ordinary final metadata/proof changed: %v", response.Header) + } + for _, name := range []string{"Authorization", "Proxy-Authorization", "Connection", "X-Hop"} { + webCoverMetadataAssertAbsent(t, "final header", response.Header, name) + } + forbiddenTrailer := "X-Hop" + if authenticationInfo { + forbiddenTrailer = "Proxy-Authentication-Info" + webCoverMetadataAssertAbsent(t, "final header", response.Header, forbiddenTrailer) + wantAuthenticationInfo := `nextnonce="ordinary-final"` + if authTrailer { + wantAuthenticationInfo = "" + } + if response.Header.Get("Authentication-Info") != wantAuthenticationInfo || response.Header.Get("WWW-Authenticate") != webCoverMetadataWWW { + t.Errorf("ordinary final authentication metadata lost: %v", response.Header) + } + } + webCoverMetadataAssertAbsent(t, "announced trailer", response.Trailer, forbiddenTrailer) + for _, value := range response.Header.Values("Trailer") { + for _, name := range strings.Split(value, ",") { + if strings.EqualFold(strings.TrimSpace(name), forbiddenTrailer) { + t.Errorf("unsafe Trailer announcement survived: %q", value) + } + } + } + if !webCoverMetadataHasField(response.Trailer, "X-End") { + t.Errorf("ordinary X-End trailer announcement lost: %v", response.Trailer) + } + early := make([]byte, len(webCoverMetadataEarly)) + if _, err := io.ReadFull(response.Body, early); err != nil { + t.Fatalf("early streaming read: %v", err) + } + if string(early) != webCoverMetadataEarly { + t.Errorf("early bytes = %q", early) + } + select { + case <-origin.earlySent: + case <-time.After(time.Second): + t.Fatal("owned origin did not publish the early chunk") + } + select { + case <-origin.joined: + t.Fatal("origin completed before the reader released its body gate") + default: + } + origin.ungate() + late, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if string(early)+string(late) != webCoverMetadataEarly+webCoverMetadataLate { + t.Errorf("complete streamed body = %q", string(early)+string(late)) + } + webCoverMetadataAssertAbsent(t, "late EOF trailer", response.Trailer, forbiddenTrailer) + if response.Trailer.Get("X-End") != "retained-at-eof" { + t.Errorf("ordinary late trailer lost: %v", response.Trailer) + } + if authTrailer && response.Trailer.Get("Authentication-Info") != `nextnonce="ordinary-trailer"` { + t.Errorf("ordinary Authentication-Info trailer lost: %v", response.Trailer) + } + hintsMu.Lock() + observed := append([]webCoverMetadataHint(nil), hints...) + hintsMu.Unlock() + if authenticationInfo { + codes := []int{103, 102, 103} + if len(observed) != len(codes) { + t.Errorf("actual 1xx count = %d, want %d", len(observed), len(codes)) + } else { + for i, hint := range observed { + webCoverMetadataAssertAbsent(t, fmt.Sprintf("informational %d", i), hint.header, "Proxy-Authentication-Info") + if hint.code != codes[i] || hint.header.Get("Authentication-Info") != fmt.Sprintf(`nextnonce="ordinary-info-%d"`, i) || hint.header.Get("WWW-Authenticate") != webCoverMetadataWWW || hint.header.Get("Link") != "; rel=preload" { + t.Errorf("ordinary informational metadata changed: %d %v", hint.code, hint.header) + } + } + } + } else if len(observed) != 0 { + t.Errorf("unexpected informational responses: %v", observed) + } + webCoverMetadataJoin(t, "raw H1 origin", origin.joined) + select { + case err := <-origin.result: + if err != nil { + t.Errorf("owned raw origin: %v", err) + } + default: + t.Fatal("joined origin did not publish its result") + } + select { + case got := <-origin.request: + if got.ProtoMajor != 1 || got.ProtoMinor != 1 || got.Method != http.MethodGet || got.Host != origin.address || got.URL.Path != "/base/metadata" || got.URL.RawQuery != "operator=1&visitor=2" { + t.Errorf("actual fixed H1 origin request = %s %s Host=%q URL=%s", got.Proto, got.Method, got.Host, got.URL) + } + for _, name := range []string{"Authorization", "Proxy-Authorization", "Proxy-Authentication-Info"} { + webCoverMetadataAssertAbsent(t, "origin request", got.Header, name) + } + if authenticationInfo && got.Header.Get("Authentication-Info") != `nextnonce="ordinary-request"` { + t.Errorf("ordinary request end-to-end metadata lost: %v", got.Header) + } + default: + t.Fatal("owned origin did not publish its actual request") + } + webCoverMetadataJoin(t, "completed cover handler", coverJoined) + if targetDials.Load() != 0 || targetResolutions.Load() != 0 { + t.Errorf("public target dial/resolution = %d/%d, want 0/0", targetDials.Load(), targetResolutions.Load()) + } + t.Logf("actual H1.1 fixed origin -> HTTPS H%d: early bytes before gate, complete EOF body, target dial/resolution=0/0", proto) +} + +type webCoverMetadataOrigin struct { + address string + earlySent chan struct{} + gate chan struct{} + gateOnce sync.Once + joined chan struct{} + result chan error + request chan *http.Request +} + +func (o *webCoverMetadataOrigin) ungate() { o.gateOnce.Do(func() { close(o.gate) }) } + +func webCoverMetadataRawOrigin(t *testing.T, authenticationInfo, authTrailer bool) *webCoverMetadataOrigin { + t.Helper() + listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatal(err) + } + o := &webCoverMetadataOrigin{listener.Addr().String(), make(chan struct{}), make(chan struct{}), sync.Once{}, make(chan struct{}), make(chan error, 1), make(chan *http.Request, 1)} + var mu sync.Mutex + var raw net.Conn + closed := false + t.Cleanup(func() { + o.ungate() + _ = listener.Close() + mu.Lock() + closed = true + if raw != nil { + _ = raw.Close() + } + mu.Unlock() + webCoverMetadataJoin(t, "raw origin cleanup", o.joined) + }) + go func() { + defer close(o.joined) + _ = listener.SetDeadline(time.Now().Add(4 * time.Second)) + conn, err := listener.AcceptTCP() + if err != nil { + o.result <- err + return + } + defer conn.Close() + mu.Lock() + if closed { + mu.Unlock() + o.result <- net.ErrClosed + return + } + raw = conn + mu.Unlock() + _ = conn.SetDeadline(time.Now().Add(4 * time.Second)) + request, err := http.ReadRequest(bufio.NewReader(conn)) + if err != nil { + o.result <- err + return + } + _, err = io.Copy(io.Discard, request.Body) + _ = request.Body.Close() + if err != nil { + o.result <- err + return + } + o.request <- request.Clone(context.Background()) + if authenticationInfo { + for i, status := range []int{103, 102, 103} { + if _, err := fmt.Fprintf(conn, "HTTP/1.1 %d %s\r\nProxy-Authentication-Info: nextnonce=\"fictional-info-proxy-%d\"\r\nAuthentication-Info: nextnonce=\"ordinary-info-%d\"\r\nWWW-Authenticate: %s\r\nLink: ; rel=preload\r\n\r\n", status, http.StatusText(status), i, i, webCoverMetadataWWW); err != nil { + o.result <- err + return + } + } + } + headers := "Connection: X-Hop\r\nX-Hop: fictional-initial-hop\r\nTrailer: X-Hop, X-End\r\n" + if authenticationInfo { + // Do not declare a final header's own name as a trailer: native + // writers can migrate/merge it. These independent cases isolate + // final-field preservation from late trailer preservation. + headers = "WWW-Authenticate: " + webCoverMetadataWWW + "\r\n" + if authTrailer { + headers += "Trailer: Proxy-Authentication-Info, Authentication-Info, X-End\r\n" + } else { + headers += "Proxy-Authentication-Info: nextnonce=\"fictional-final-proxy\"\r\nAuthentication-Info: nextnonce=\"ordinary-final\"\r\nTrailer: X-End\r\n" + } + } + // Deliberately no Connection: close: its parser information-loss boundary + // is separate. Nominations come only from the initial header, not trailers. + if _, err := fmt.Fprintf(conn, "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nX-Site: ordinary\r\n%s\r\n%x\r\n%s\r\n", headers, len(webCoverMetadataEarly), webCoverMetadataEarly); err != nil { + o.result <- err + return + } + close(o.earlySent) + select { + case <-o.gate: + case <-time.After(3 * time.Second): + o.result <- errors.New("owned origin body gate timed out") + return + } + trailers := "X-Hop: fictional-late-hop\r\nX-End: retained-at-eof\r\n" + if authenticationInfo { + trailers = "X-End: retained-at-eof\r\n" + if authTrailer { + trailers = "Proxy-Authentication-Info: nextnonce=\"fictional-trailer-proxy\"\r\nAuthentication-Info: nextnonce=\"ordinary-trailer\"\r\nX-End: retained-at-eof\r\n" + } + } + _, err = fmt.Fprintf(conn, "%x\r\n%s\r\n0\r\n%s\r\n", len(webCoverMetadataLate), webCoverMetadataLate, trailers) + o.result <- err + }() + return o +} + +func webCoverMetadataPublicTransport(t *testing.T, clientTLS *tls.Config, proto int) http.RoundTripper { + t.Helper() + if proto == 3 { + cfg := clientTLS.Clone() + cfg.NextProtos = []string{http3.NextProtoH3} + rt := &http3.Transport{TLSClientConfig: cfg, QUICConfig: &quic.Config{HandshakeIdleTimeout: 2 * time.Second, MaxIdleTimeout: 3 * time.Second}} + t.Cleanup(func() { _ = rt.Close() }) + return rt + } + rt := &http.Transport{Proxy: nil, TLSClientConfig: clientTLS.Clone(), ForceAttemptHTTP2: proto == 2, DialContext: (&net.Dialer{Timeout: time.Second}).DialContext, TLSHandshakeTimeout: 2 * time.Second, ResponseHeaderTimeout: 2 * time.Second, IdleConnTimeout: time.Second} + if proto == 1 { + rt.TLSClientConfig.NextProtos = []string{webHTTP11ALPN} + rt.TLSNextProto = make(map[string]func(string, *tls.Conn) http.RoundTripper) + } + t.Cleanup(rt.CloseIdleConnections) + return rt +} + +func webCoverMetadataJoin(t *testing.T, name string, joined <-chan struct{}) { + t.Helper() + select { + case <-joined: + case <-time.After(2 * time.Second): + t.Errorf("%s did not join within its independent budget", name) + } +} + +func webCoverMetadataHasField(header http.Header, name string) bool { + for field := range header { + if strings.EqualFold(field, name) { + return true + } + } + return false +} + +func webCoverMetadataAssertAbsent(t *testing.T, phase string, header http.Header, name string) { + t.Helper() + if webCoverMetadataHasField(header, name) { + t.Errorf("%s leaked %s (including empty declarations): %v", phase, name, header) + } +} diff --git a/internal/tunnel/web_cover_options_test.go b/internal/tunnel/web_cover_options_test.go new file mode 100644 index 0000000..ca05734 --- /dev/null +++ b/internal/tunnel/web_cover_options_test.go @@ -0,0 +1,192 @@ +package tunnel + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "sync/atomic" + "testing" + "time" + + "github.com/cppla/autocar/internal/cover" +) + +// Use the real combined listener and ordinary clients, not a handler recorder: +// net/http's general OPTIONS handler can intercept '*' before cover is called. +func TestWebCoverOptionsAsteriskStaticAcrossProtocols(t *testing.T) { + website, err := cover.NewStaticHandler(t.TempDir()) + if err != nil { + t.Fatal(err) + } + var dials, resolves atomic.Int64 + server, clientTLS := webAltSvcCombinedServer(t, website, webAltSvcForbiddenDialer(&dials), &resolves) + for _, proto := range []int{1, 2, 3} { + t.Run(fmt.Sprintf("h%d", proto), func(t *testing.T) { + rt := webAltSvcPublicTransport(t, clientTLS, proto) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + request := webCoverOptionsRequest(t, ctx, server, proto, "*", "asterisk") + result := webAltSvcRoundTrip(t, rt, request, proto) + if result.status != http.StatusMethodNotAllowed || result.body != "Method Not Allowed\n" || result.header.Get("Allow") != "GET, HEAD" { + t.Errorf("real OPTIONS * static response = %d/%q/Allow:%q, want 405/Method Not Allowed/GET, HEAD", result.status, result.body, result.header.Get("Allow")) + } + webCoverOptionsPublicHeaders(t, result, server) + }) + } + if dials.Load() != 0 || resolves.Load() != 0 { + t.Fatalf("ordinary OPTIONS touched tunnel dial/resolution: %d/%d", dials.Load(), resolves.Load()) + } +} + +func TestWebCoverOptionsFixedOriginTargetsAcrossProtocols(t *testing.T) { + observed := make(chan webCoverOptionsObserved, 12) + var unexpectedRequests atomic.Int64 + origin := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + joined := make(chan struct{}) + defer close(joined) + observation := webCoverOptionsObserved{ + method: r.Method, requestURI: r.RequestURI, host: r.Host, + path: r.URL.Path, rawPath: r.URL.RawPath, rawQuery: r.URL.RawQuery, + origin: r.Header.Get("Origin"), referer: r.Header.Get("Referer"), + authorization: r.Header.Get("Authorization"), proxyAuthorization: r.Header.Get("Proxy-Authorization"), + caseName: r.Header.Get("X-Options-Case"), joined: joined, + } + // This fixture has exactly twelve sequential requests. Never leave an + // unexpected retry blocked on test observation during independent Close. + select { + case observed <- observation: + default: + unexpectedRequests.Add(1) + } + w.Header().Set("Alt-Svc", `h3=":1"; ma=1`) + w.Header().Set("X-Options-Origin", "ordinary") + w.WriteHeader(http.StatusAccepted) + _, _ = io.WriteString(w, "origin options\n") + })) + origin.Config.DisableGeneralOptionsHandler = true + origin.Config.ReadHeaderTimeout = 2 * time.Second + origin.Config.ReadTimeout = 2 * time.Second + origin.Config.WriteTimeout = 2 * time.Second + origin.Config.IdleTimeout = 2 * time.Second + origin.Start() + t.Cleanup(func() { origin.CloseClientConnections(); origin.Close() }) + target, err := url.Parse(origin.URL + "/base?operator=one") + if err != nil { + t.Fatal(err) + } + upstream := &http.Transport{ + Proxy: nil, + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + if network != "tcp" || address != target.Host { + return nil, fmt.Errorf("OPTIONS fixture blocked non-owned origin network/authority: %q/%q", network, address) + } + return (&net.Dialer{Timeout: time.Second}).DialContext(ctx, network, address) + }, + ResponseHeaderTimeout: 2 * time.Second, IdleConnTimeout: time.Second, + } + t.Cleanup(upstream.CloseIdleConnections) + website, err := cover.NewReverseProxyHandler(target, upstream) + if err != nil { + t.Fatal(err) + } + var dials, resolves atomic.Int64 + server, clientTLS := webAltSvcCombinedServer(t, website, webAltSvcForbiddenDialer(&dials), &resolves) + for _, proto := range []int{1, 2, 3} { + t.Run(fmt.Sprintf("h%d", proto), func(t *testing.T) { + rt := webAltSvcPublicTransport(t, clientTLS, proto) + for _, test := range []struct { + name, target, requestURI, path, rawQuery string + }{ + {name: "asterisk", target: "*", requestURI: "*", path: "*"}, + {name: "slash_star", target: "/*", requestURI: "/base/*?operator=one", path: "/base/*", rawQuery: "operator=one"}, + {name: "escaped_star", target: "/%2a", requestURI: "/base/%2a?operator=one", path: "/base/*", rawQuery: "operator=one"}, + {name: "query_star", target: "/path?literal=*", requestURI: "/base/path?operator=one&literal=*", path: "/base/path", rawQuery: "operator=one&literal=*"}, + } { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + request := webCoverOptionsRequest(t, ctx, server, proto, test.target, test.name) + result := webAltSvcRoundTrip(t, rt, request, proto) + if result.status != http.StatusAccepted || result.body != "origin options\n" || result.header.Get("X-Options-Origin") != "ordinary" { + t.Errorf("fixed-origin ordinary response = %d/%q/X-Options-Origin:%q, want 202/origin options/ordinary", result.status, result.body, result.header.Get("X-Options-Origin")) + } + webCoverOptionsPublicHeaders(t, result, server) + select { + case got := <-observed: + if got.method != http.MethodOptions || got.requestURI != test.requestURI || got.host != target.Host || got.path != test.path || got.rawQuery != test.rawQuery { + t.Errorf("actual upstream method/URI/Host/path/query = %s/%q/%q/%q/%q (observed RawPath:%q), want OPTIONS/%q/%q/%q/%q", got.method, got.requestURI, got.host, got.path, got.rawQuery, got.rawPath, test.requestURI, target.Host, test.path, test.rawQuery) + } + t.Logf("actual origin OPTIONS URI=%q path=%q query=%q RawPath=%q", got.requestURI, got.path, got.rawQuery, got.rawPath) + if got.caseName != test.name || got.origin != "https://visitor.invalid" || got.referer != "https://visitor.invalid/app" { + t.Errorf("origin/referer/case changed or fabricated: %q/%q/%q", got.origin, got.referer, got.caseName) + } + if got.authorization != "" || got.proxyAuthorization != "" { + t.Error("ordinary OPTIONS forwarded request credentials") + } + select { + case <-got.joined: + case <-time.After(time.Second): + t.Error("actual origin OPTIONS handler did not join") + } + case <-time.After(500 * time.Millisecond): + t.Error("completed public response never reached configured origin") + } + // No prior request may be mistaken for the next matrix case. + if len(observed) != 0 { + t.Error("unexpected additional origin OPTIONS request") + } + }) + } + }) + } + if dials.Load() != 0 || resolves.Load() != 0 || unexpectedRequests.Load() != 0 { + t.Fatalf("ordinary OPTIONS tunnel dial/resolution/unexpected origin counters = %d/%d/%d", dials.Load(), resolves.Load(), unexpectedRequests.Load()) + } +} + +type webCoverOptionsObserved struct { + method, requestURI, host, path, rawPath, rawQuery string + origin, referer, authorization, proxyAuthorization string + caseName string + joined <-chan struct{} +} + +func webCoverOptionsRequest(t *testing.T, ctx context.Context, server *WebServer, proto int, target, caseName string) *http.Request { + t.Helper() + requestTarget := target + if target == "*" { + requestTarget = "/" + } + request, err := http.NewRequestWithContext(ctx, http.MethodOptions, "https://"+webAltSvcAddress(server, proto)+requestTarget, nil) + if err != nil { + t.Fatal(err) + } + if target == "*" { + request.URL.Path, request.URL.RawPath, request.URL.RawQuery = "*", "", "" + if request.URL.RequestURI() != "*" { + t.Fatal("fixture did not construct an actual asterisk request target") + } + } + request.Host = "requester.invalid" + request.Header.Set("Origin", "https://visitor.invalid") + request.Header.Set("Referer", "https://visitor.invalid/app") + request.Header.Set("X-Options-Case", caseName) + request.Header.Set("Authorization", "Bearer fictional-origin-credential") + request.Header.Set("Proxy-Authorization", "Bearer invalid") + return request +} + +func webCoverOptionsPublicHeaders(t *testing.T, result webAltSvcResult, server *WebServer) { + t.Helper() + if got, want := result.header.Values("Alt-Svc"), []string{webH3AltSvcValue(server.UDPAddr())}; !reflect.DeepEqual(got, want) { + t.Errorf("ordinary OPTIONS bound Alt-Svc = %v, want %v", got, want) + } + if result.header.Get(webAuthResponseHeader) != "" || result.header.Get("Proxy-Authenticate") != "" || len(result.infos) != 0 { + t.Error("ordinary OPTIONS emitted tunnel proof/challenge or unexpected informational response") + } +} diff --git a/internal/tunnel/web_h2.go b/internal/tunnel/web_h2.go index e23359a..21d90a8 100644 --- a/internal/tunnel/web_h2.go +++ b/internal/tunnel/web_h2.go @@ -112,10 +112,11 @@ func listenWebH2WithCore(config WebH2ServerConfig, core *serverCore, auth *webAu } httpServer := &http.Server{ - Handler: handler, - TLSConfig: tlsConfig, - ReadHeaderTimeout: core.handshakeTimeout, - IdleTimeout: 90 * time.Second, + Handler: handler, + TLSConfig: tlsConfig, + ReadHeaderTimeout: core.handshakeTimeout, + IdleTimeout: 90 * time.Second, + DisableGeneralOptionsHandler: true, ConnContext: func(ctx context.Context, connection net.Conn) context.Context { // Request.TLS is normally populated by net/http, but it isn't part of // the HTTP/2 handler contract when Serve is given a TLS listener. Keep