diff --git a/.golangci.yml b/.golangci.yml index c2c6e80e..886b55fb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -61,11 +61,6 @@ linters: - legacy - std-error-handling rules: - # Keep h2c and HTTP/1.1 Upgrade:h2c compatibility until an explicit migration. - - linters: - - staticcheck - path: ^pkg/util/vhost/http\.go$ - text: "^SA1019:" - linters: - errcheck - maligned diff --git a/Release.md b/Release.md index ba639e01..6f209a78 100644 --- a/Release.md +++ b/Release.md @@ -1,4 +1,5 @@ ## Fixes +* HTTP vhost servers no longer support HTTP/1.1 `Upgrade: h2c` requests. Cleartext HTTP/2 prior-knowledge remains supported. * Fixed control-session replacement leaks when frpc reconnects through a half-open TCP multiplexed connection. * Fixed an SSH tunnel gateway panic when handling malformed exec requests. diff --git a/go.mod b/go.mod index af335865..fa2f7b31 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 github.com/coreos/go-oidc/v3 v3.14.1 - github.com/fatedier/golib v0.8.0 + github.com/fatedier/golib v0.8.1-0.20260722102903-f59d12dbf4a6 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.0 diff --git a/go.sum b/go.sum index ebb1a5ba..314f5179 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fatedier/golib v0.8.0 h1:LQC8Mly/N71CUnFmgZMfQGrCA4h0tghg9pcDx58uG10= -github.com/fatedier/golib v0.8.0/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw= +github.com/fatedier/golib v0.8.1-0.20260722102903-f59d12dbf4a6 h1:ZoGMMzVe+PqeJfLG2xxh1OjAcgpObIqrUqDAkgL9618= +github.com/fatedier/golib v0.8.1-0.20260722102903-f59d12dbf4a6/go.mod h1:ArUGvPg2cOw/py2RAuBt46nNZH2VQ5Z70p109MAZpJw= github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6 h1:u92UUy6FURPmNsMBUuongRWC0rBqN6gd01Dzu+D21NE= github.com/fatedier/yamux v0.0.0-20250825093530-d0154be01cd6/go.mod h1:c5/tk6G0dSpXGzJN7Wk1OEie8grdSJAmeawId9Zvd34= github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= diff --git a/pkg/util/vhost/http.go b/pkg/util/vhost/http.go index 865ff9d5..05960fdc 100644 --- a/pkg/util/vhost/http.go +++ b/pkg/util/vhost/http.go @@ -28,8 +28,6 @@ import ( libio "github.com/fatedier/golib/io" "github.com/fatedier/golib/pool" - "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" httppkg "github.com/fatedier/frp/pkg/util/http" "github.com/fatedier/frp/pkg/util/log" @@ -139,7 +137,7 @@ func NewHTTPReverseProxy(option HTTPReverseProxyOptions, vhostRouter *Routers) * _, _ = rw.Write(getNotFoundPageContent()) }, } - rp.proxy = h2c.NewHandler(proxy, &http2.Server{}) + rp.proxy = proxy return rp } diff --git a/pkg/util/vhost/http_test.go b/pkg/util/vhost/http_test.go index 237ae903..a6493b08 100644 --- a/pkg/util/vhost/http_test.go +++ b/pkg/util/vhost/http_test.go @@ -1,14 +1,94 @@ package vhost import ( + "bufio" + "fmt" + "net" + "net/http" "net/http/httptest" "testing" + "time" "github.com/stretchr/testify/require" httppkg "github.com/fatedier/frp/pkg/util/http" ) +func TestHTTPServerProtocols(t *testing.T) { + rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters()) + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) + server := &http.Server{ + Handler: rp, + ReadHeaderTimeout: time.Second, + Protocols: protocols, + } + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + serveErr := make(chan error, 1) + go func() { + serveErr <- server.Serve(listener) + }() + defer func() { + require.NoError(t, server.Close()) + require.ErrorIs(t, <-serveErr, http.ErrServerClosed) + }() + + require.True(t, server.Protocols.HTTP1()) + require.True(t, server.Protocols.UnencryptedHTTP2()) + + t.Run("HTTP/1.1", func(t *testing.T) { + transport := &http.Transport{Protocols: httpProtocols(true, false)} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport} + response, err := client.Get("http://" + listener.Addr().String() + "/") + require.NoError(t, err) + defer response.Body.Close() + + require.Equal(t, "HTTP/1.1", response.Proto) + require.Equal(t, http.StatusNotFound, response.StatusCode) + }) + + t.Run("HTTP/2 prior knowledge", func(t *testing.T) { + transport := &http.Transport{Protocols: httpProtocols(false, true)} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport} + response, err := client.Get("http://" + listener.Addr().String() + "/") + require.NoError(t, err) + defer response.Body.Close() + + require.Equal(t, "HTTP/2.0", response.Proto) + require.Equal(t, http.StatusNotFound, response.StatusCode) + }) + + t.Run("HTTP/1.1 Upgrade h2c", func(t *testing.T) { + conn, err := net.Dial("tcp", listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + _, err = fmt.Fprintf(conn, + "GET / HTTP/1.1\r\nHost: %s\r\n"+ + "Connection: Upgrade, HTTP2-Settings\r\nUpgrade: h2c\r\n"+ + "HTTP2-Settings: AAMAAABkAAQCAAAAAAIAAAAA\r\n\r\n", + listener.Addr()) + require.NoError(t, err) + response, err := http.ReadResponse(bufio.NewReader(conn), nil) + require.NoError(t, err) + defer response.Body.Close() + + require.NotEqual(t, http.StatusSwitchingProtocols, response.StatusCode) + }) +} + +func httpProtocols(http1, unencryptedHTTP2 bool) *http.Protocols { + protocols := new(http.Protocols) + protocols.SetHTTP1(http1) + protocols.SetUnencryptedHTTP2(unencryptedHTTP2) + return protocols +} + func TestCheckRouteAuthByRequest(t *testing.T) { rc := &RouteConfig{ Username: "alice", diff --git a/server/service.go b/server/service.go index 39399ff9..0a174052 100644 --- a/server/service.go +++ b/server/service.go @@ -300,10 +300,14 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) { svr.rc.HTTPReverseProxy = rp address := net.JoinHostPort(cfg.ProxyBindAddr, strconv.Itoa(cfg.VhostHTTPPort)) + protocols := new(http.Protocols) + protocols.SetHTTP1(true) + protocols.SetUnencryptedHTTP2(true) server := &http.Server{ Addr: address, Handler: rp, ReadHeaderTimeout: 60 * time.Second, + Protocols: protocols, } var l net.Listener if httpMuxOn { diff --git a/server/service_test.go b/server/service_test.go index 10b1a3dd..0ebec604 100644 --- a/server/service_test.go +++ b/server/service_test.go @@ -18,12 +18,14 @@ import ( "context" "errors" "net" + "net/http" "runtime" "sync" "sync/atomic" "testing" "time" + "github.com/fatedier/golib/net/mux" "github.com/stretchr/testify/require" "github.com/fatedier/frp/pkg/auth" @@ -77,6 +79,71 @@ func TestWriteWithDeadlineTimesOutAndClearsDeadline(t *testing.T) { } } +func TestSharedPortHTTPListenerProtocols(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + sharedMux := mux.NewMux(listener) + httpListener := sharedMux.ListenHTTP(1) + muxServeErr := make(chan error, 1) + go func() { + muxServeErr <- sharedMux.Serve() + }() + + newProtocols := func(http1, unencryptedHTTP2 bool) *http.Protocols { + protocols := new(http.Protocols) + protocols.SetHTTP1(http1) + protocols.SetUnencryptedHTTP2(unencryptedHTTP2) + return protocols + } + + const handlerProtocolHeader = "X-Test-Handler-Protocol" + httpServer := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(handlerProtocolHeader, r.Proto) + w.WriteHeader(http.StatusNoContent) + }), + ReadHeaderTimeout: time.Second, + Protocols: newProtocols(true, true), + } + httpServeErr := make(chan error, 1) + go func() { + httpServeErr <- httpServer.Serve(httpListener) + }() + t.Cleanup(func() { + require.NoError(t, httpServer.Close()) + require.ErrorIs(t, waitForResult(t, httpServeErr, "shared HTTP server to stop"), http.ErrServerClosed) + require.NoError(t, sharedMux.Close()) + require.ErrorIs(t, waitForResult(t, muxServeErr, "shared mux to stop"), net.ErrClosed) + }) + + for _, tc := range []struct { + name string + http1 bool + unencryptedHTTP2 bool + expectedProtocol string + }{ + {name: "HTTP/1.1", http1: true, expectedProtocol: "HTTP/1.1"}, + {name: "HTTP/2 prior knowledge", unencryptedHTTP2: true, expectedProtocol: "HTTP/2.0"}, + } { + t.Run(tc.name, func(t *testing.T) { + transport := &http.Transport{ + Protocols: newProtocols(tc.http1, tc.unencryptedHTTP2), + } + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 3 * time.Second} + request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://"+listener.Addr().String()+"/", nil) + require.NoError(t, err) + response, err := client.Do(request) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, response.StatusCode) + require.Equal(t, tc.expectedProtocol, response.Proto) + require.Equal(t, tc.expectedProtocol, response.Header.Get(handlerProtocolHeader)) + require.NoError(t, response.Body.Close()) + }) + } +} + func TestServiceControlHandoffSkipsStalePendingGeneration(t *testing.T) { svr := newControlTestService(t) metrics := newCountingServerMetrics()