mirror of
https://github.com/fatedier/frp.git
synced 2026-10-15 03:45:57 +08:00
432 lines
14 KiB
Go
432 lines
14 KiB
Go
package vhost
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
|
)
|
|
|
|
func TestHTTPConnectTunnel(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
framing string
|
|
pipelined bool
|
|
}{
|
|
{name: "plain"},
|
|
{name: "plain-early", pipelined: true},
|
|
{name: "cl0", framing: "Content-Length: 0\r\n"},
|
|
{name: "cl0-early", framing: "Content-Length: 0\r\n", pipelined: true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
remote, backend := net.Pipe()
|
|
defer remote.Close()
|
|
defer backend.Close()
|
|
require.NoError(t, backend.SetDeadline(time.Now().Add(5*time.Second)))
|
|
|
|
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
|
|
require.NoError(t, rp.Register(RouteConfig{
|
|
Domain: "target.example.com",
|
|
Username: "alice",
|
|
Password: "secret",
|
|
CreateConnFn: func(string) (net.Conn, error) {
|
|
return remote, nil
|
|
},
|
|
}))
|
|
listener := netpkg.NewInternalListener()
|
|
server := &http.Server{Handler: rp, ReadHeaderTimeout: time.Second}
|
|
serveErr := make(chan error, 1)
|
|
go func() {
|
|
serveErr <- server.Serve(listener)
|
|
}()
|
|
defer func() {
|
|
require.NoError(t, server.Close())
|
|
require.ErrorIs(t, <-serveErr, http.ErrServerClosed)
|
|
}()
|
|
|
|
client, incoming := net.Pipe()
|
|
defer client.Close()
|
|
defer incoming.Close()
|
|
require.NoError(t, client.SetDeadline(time.Now().Add(5*time.Second)))
|
|
require.NoError(t, listener.PutConn(incoming))
|
|
|
|
proxyAuth := httppkg.BasicAuth("alice", "secret")
|
|
request := "CONNECT target.example.com:443 HTTP/1.1\r\nHost: target.example.com:443\r\n" +
|
|
"X-Connect-Test: tunnel\r\nProxy-Authorization: " + proxyAuth + "\r\n" + tc.framing + "\r\n"
|
|
if tc.pipelined {
|
|
// One pipe write puts the tunnel data in net/http's request buffer.
|
|
request += "early data"
|
|
}
|
|
_, err := io.WriteString(client, request)
|
|
require.NoError(t, err)
|
|
backendReader := bufio.NewReader(backend)
|
|
req, err := http.ReadRequest(backendReader)
|
|
require.NoError(t, err)
|
|
defer req.Body.Close()
|
|
require.Equal(t, http.MethodConnect, req.Method)
|
|
require.Equal(t, "target.example.com:443", req.Host)
|
|
require.Equal(t, "target.example.com:443", req.URL.Host)
|
|
require.Equal(t, "tunnel", req.Header.Get("X-Connect-Test"))
|
|
require.Equal(t, proxyAuth, req.Header.Get("Proxy-Authorization"))
|
|
require.Zero(t, req.ContentLength)
|
|
require.Empty(t, req.TransferEncoding)
|
|
if tc.pipelined {
|
|
data := make([]byte, len("early data"))
|
|
_, err = io.ReadFull(backendReader, data)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "early data", string(data))
|
|
}
|
|
|
|
writeErr := make(chan error, 1)
|
|
go func() {
|
|
_, err := io.WriteString(backend, "HTTP/1.1 200 Connection Established\r\n\r\nreply")
|
|
writeErr <- err
|
|
}()
|
|
clientReader := bufio.NewReader(client)
|
|
response, err := http.ReadResponse(clientReader, req)
|
|
require.NoError(t, err)
|
|
defer response.Body.Close()
|
|
require.Equal(t, http.StatusOK, response.StatusCode)
|
|
reply := make([]byte, len("reply"))
|
|
_, err = io.ReadFull(clientReader, reply)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "reply", string(reply))
|
|
require.NoError(t, <-writeErr)
|
|
|
|
_, err = io.WriteString(client, "later data")
|
|
require.NoError(t, err)
|
|
data := make([]byte, len("later data"))
|
|
_, err = io.ReadFull(backendReader, data)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "later data", string(data))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHTTPConnectRejectsContent(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
framing string
|
|
body string
|
|
}{
|
|
{name: "content-length", framing: "Content-Length: 1\r\n", body: "X"},
|
|
{name: "chunked", framing: "Transfer-Encoding: chunked\r\n", body: "1\r\nX\r\n0\r\n\r\n"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
backendCalls := 0
|
|
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
|
|
require.NoError(t, rp.Register(RouteConfig{
|
|
Domain: "target.example.com",
|
|
CreateConnFn: func(string) (net.Conn, error) {
|
|
backendCalls++
|
|
return nil, io.ErrClosedPipe
|
|
},
|
|
}))
|
|
req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(
|
|
"CONNECT target.example.com:443 HTTP/1.1\r\nHost: target.example.com:443\r\n" + tc.framing + "\r\n" + tc.body)))
|
|
require.NoError(t, err)
|
|
defer req.Body.Close()
|
|
rw := &connectTestResponseWriter{ResponseRecorder: httptest.NewRecorder()}
|
|
|
|
rp.ServeHTTP(rw, req)
|
|
|
|
require.Equal(t, http.StatusBadRequest, rw.Code)
|
|
require.Zero(t, rw.hijackCalls, "reject content before Hijack")
|
|
require.Zero(t, backendCalls, "reject content before creating a backend")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHTTPConnectHeaderWriteFailure(t *testing.T) {
|
|
for _, originalBody := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("originalBody=%v", originalBody), func(t *testing.T) {
|
|
client, incoming := net.Pipe()
|
|
defer client.Close()
|
|
defer incoming.Close()
|
|
remote, backend := net.Pipe()
|
|
defer remote.Close()
|
|
defer backend.Close()
|
|
clientConn := &connectTestConn{Conn: incoming}
|
|
remoteConn := &connectTestConn{Conn: remote, failWrites: true}
|
|
rp := NewHTTPReverseProxy(HTTPReverseProxyOptions{}, NewRouters())
|
|
require.NoError(t, rp.Register(RouteConfig{
|
|
Domain: "target.example.com",
|
|
CreateConnFn: func(string) (net.Conn, error) {
|
|
return remoteConn, nil
|
|
},
|
|
}))
|
|
req := rp.injectRequestInfoToCtx(httptest.NewRequest(http.MethodConnect, "target.example.com:443", nil))
|
|
require.Equal(t, "target.example.com:443", req.Host)
|
|
body := &connectTestBody{}
|
|
if originalBody {
|
|
req.Body = body
|
|
}
|
|
requestBody := req.Body
|
|
getBodyCalls := 0
|
|
req.GetBody = func() (io.ReadCloser, error) {
|
|
getBodyCalls++
|
|
return body, nil
|
|
}
|
|
req.Header.Set("Content-Length", "0")
|
|
req.Header.Set("Trailer", "X-Connect-Trailer")
|
|
req.Trailer = http.Header{"X-Connect-Trailer": {"value"}}
|
|
originalHeader, originalTrailer := req.Header.Clone(), req.Trailer.Clone()
|
|
rw := &connectTestResponseWriter{
|
|
ResponseRecorder: httptest.NewRecorder(),
|
|
conn: clientConn,
|
|
buffered: bufio.NewReadWriter(bufio.NewReader(clientConn), bufio.NewWriter(clientConn)),
|
|
}
|
|
|
|
rp.connectHandler(rw, req)
|
|
|
|
require.Equal(t, 1, rw.hijackCalls)
|
|
require.Zero(t, body.reads, "do not read the original Body after Hijack")
|
|
require.Zero(t, body.closes, "do not close the original Body after Hijack")
|
|
require.Positive(t, remoteConn.writes.Load(), "exercise CONNECT header write failure")
|
|
require.True(t, remoteConn.closed.Load(), "close backend on CONNECT header write failure")
|
|
require.True(t, clientConn.closed.Load(), "close client on CONNECT header write failure")
|
|
require.Zero(t, remoteConn.reads.Load(), "do not start Join after header write failure")
|
|
require.Zero(t, clientConn.reads.Load(), "do not start Join after header write failure")
|
|
require.Zero(t, getBodyCalls)
|
|
require.Equal(t, requestBody, req.Body)
|
|
require.NotNil(t, req.GetBody)
|
|
require.Equal(t, originalHeader, req.Header)
|
|
require.Equal(t, originalTrailer, req.Trailer)
|
|
})
|
|
}
|
|
}
|
|
|
|
type connectTestResponseWriter struct {
|
|
*httptest.ResponseRecorder
|
|
conn net.Conn
|
|
buffered *bufio.ReadWriter
|
|
hijackCalls int
|
|
}
|
|
|
|
func (rw *connectTestResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
rw.hijackCalls++
|
|
if rw.conn == nil {
|
|
return nil, nil, io.ErrClosedPipe
|
|
}
|
|
return rw.conn, rw.buffered, nil
|
|
}
|
|
|
|
type connectTestConn struct {
|
|
net.Conn
|
|
failWrites bool
|
|
closed atomic.Bool
|
|
reads atomic.Int32
|
|
writes atomic.Int32
|
|
}
|
|
|
|
func (c *connectTestConn) Read(p []byte) (int, error) {
|
|
c.reads.Add(1)
|
|
return c.Conn.Read(p)
|
|
}
|
|
|
|
func (c *connectTestConn) Write(p []byte) (int, error) {
|
|
c.writes.Add(1)
|
|
if c.failWrites {
|
|
return 0, io.ErrClosedPipe
|
|
}
|
|
return c.Conn.Write(p)
|
|
}
|
|
|
|
func (c *connectTestConn) Close() error {
|
|
c.closed.Store(true)
|
|
return c.Conn.Close()
|
|
}
|
|
|
|
type connectTestBody struct {
|
|
reads int
|
|
closes int
|
|
}
|
|
|
|
func (b *connectTestBody) Read([]byte) (int, error) {
|
|
b.reads++
|
|
return 0, io.ErrUnexpectedEOF
|
|
}
|
|
|
|
func (b *connectTestBody) Close() error {
|
|
b.closes++
|
|
return nil
|
|
}
|
|
|
|
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",
|
|
Password: "secret",
|
|
}
|
|
|
|
t.Run("accepts nil route config", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
require.True(t, checkRouteAuthByRequest(req, nil))
|
|
})
|
|
|
|
t.Run("accepts route without credentials", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
require.True(t, checkRouteAuthByRequest(req, &RouteConfig{}))
|
|
})
|
|
|
|
t.Run("accepts authorization header", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.SetBasicAuth("alice", "secret")
|
|
require.True(t, checkRouteAuthByRequest(req, rc))
|
|
})
|
|
|
|
t.Run("accepts proxy authorization header", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
|
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "secret"))
|
|
require.True(t, checkRouteAuthByRequest(req, rc))
|
|
})
|
|
|
|
t.Run("rejects authorization fallback for proxy request", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
|
req.SetBasicAuth("alice", "secret")
|
|
require.False(t, checkRouteAuthByRequest(req, rc))
|
|
})
|
|
|
|
t.Run("rejects wrong proxy authorization even when authorization matches", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
|
req.SetBasicAuth("alice", "secret")
|
|
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "wrong"))
|
|
require.False(t, checkRouteAuthByRequest(req, rc))
|
|
})
|
|
|
|
t.Run("rejects when neither header matches", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
|
req.SetBasicAuth("alice", "wrong")
|
|
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "wrong"))
|
|
require.False(t, checkRouteAuthByRequest(req, rc))
|
|
})
|
|
|
|
t.Run("rejects proxy authorization on direct request", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("alice", "secret"))
|
|
require.False(t, checkRouteAuthByRequest(req, rc))
|
|
})
|
|
}
|
|
|
|
func TestGetRequestRouteUser(t *testing.T) {
|
|
t.Run("proxy request uses proxy authorization username", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
|
req.Host = "target.example.com"
|
|
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("proxy-user", "proxy-pass"))
|
|
req.SetBasicAuth("direct-user", "direct-pass")
|
|
|
|
require.Equal(t, "proxy-user", getRequestRouteUser(req))
|
|
})
|
|
|
|
t.Run("connect request keeps proxy authorization routing", func(t *testing.T) {
|
|
req := httptest.NewRequest("CONNECT", "http://target.example.com:443", nil)
|
|
req.Host = "target.example.com:443"
|
|
req.Header.Set("Proxy-Authorization", httppkg.BasicAuth("proxy-user", "proxy-pass"))
|
|
req.SetBasicAuth("direct-user", "direct-pass")
|
|
|
|
require.Equal(t, "proxy-user", getRequestRouteUser(req))
|
|
})
|
|
|
|
t.Run("direct request uses authorization username", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "/", nil)
|
|
req.Host = "example.com"
|
|
req.SetBasicAuth("direct-user", "direct-pass")
|
|
|
|
require.Equal(t, "direct-user", getRequestRouteUser(req))
|
|
})
|
|
|
|
t.Run("proxy request does not fall back when proxy authorization is invalid", func(t *testing.T) {
|
|
req := httptest.NewRequest("GET", "http://target.example.com/", nil)
|
|
req.Host = "target.example.com"
|
|
req.Header.Set("Proxy-Authorization", "Basic !!!")
|
|
req.SetBasicAuth("direct-user", "direct-pass")
|
|
|
|
require.Empty(t, getRequestRouteUser(req))
|
|
})
|
|
}
|