diff --git a/README.md b/README.md index ab9ef37b..9c7bc80d 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,7 @@ frp also offers a P2P connect mode. * [HTTP X-Forwarded-For](#http-x-forwarded-for) * [Proxy Protocol](#proxy-protocol) * [Require HTTP Basic Auth (Password) for Web Services](#require-http-basic-auth-password-for-web-services) + * [Require HTTPS client certificate for Web Services](#require-https-client-certificate-for-web-services) * [Custom Subdomain Names](#custom-subdomain-names) * [URL Routing](#url-routing) * [TCP Port Multiplexing](#tcp-port-multiplexing) @@ -1046,11 +1047,11 @@ You can enable Proxy Protocol support in nginx to expose user's real IP in HTTP ### Require HTTP Basic Auth (Password) for Web Services -Anyone who can guess your tunnel URL can access your local web server unless you protect it with a password. +Anyone who can guess your HTTP tunnel URL can access your local web server unless you protect it with a password. This enforces HTTP Basic Auth on all requests with the username and password specified in frpc's configure file. -It can only be enabled when proxy type is http. +It can only be enabled when proxy type is http. For https protection, see [Require HTTPS client certificate for Web Services](#require-https-client-certificate-for-web-services). ```toml # frpc.toml @@ -1066,6 +1067,44 @@ httpPassword = "abc" Visit `http://test.example.com` in the browser and now you are prompted to enter the username and password. +### Require HTTPS client certificate for Web Services + +Anyone who can guess your HTTPS tunnel URL can access your local web server unless you protect it with a [Client Certificate](https://en.wikipedia.org/wiki/Transport_Layer_Security#Client-authenticated_TLS_handshake). + +This [mutual authentication](https://en.wikipedia.org/wiki/Mutual_authentication) validates the HTTPS client's certificate on all requests, with each accepted certificate file specified in frpc's configure file. + +It can only be enabled when proxy type is https. For http protection, see [Require HTTP Basic Auth (Password) for Web Services](#require-http-basic-auth-password-for-web-services). + +```toml +[[proxies]] +name = "web" +type = "https" +customDomains = ["test.example.com"] + + [proxies.plugin] + type = "https2http" + localAddr = "127.0.0.1:80" + crtPath = server.crt" + keyPath = "key.pem" + clientCertificates = ["authorizedClient_cert.pem"] +``` + +In this situation, the client certificate can be self-signed without any detriment to security. Multiple certificates can be generated and allowed to access the service. + +Generate a .cert file **to use with frpc**, and a corresponding .pfx file **to install on Windows, Linux, or your browser**. The following instructions require the OpenSSL binary installed: + +```bash +# Windows +openssl req -x509 -newkey rsa:4096 -keyout authorizedClient_cert.pem -out authorizedClient_key.pem -sha256 -days 3650 -nodes -subj "/CN=FRP Authentication Certificate" +type authorizedClient_cert.pem authorizedClient_key.pem > authorizedClient.pem +openssl pkcs12 -export -in authorizedClient.pem -out authorizedClient.pfx -name "FRP Authentication Certificate" + +# Linux +openssl req -x509 -newkey rsa:4096 -keyout authorizedClient_cert.pem -out authorizedClient_key.pem -sha256 -days 3650 -nodes -subj "/CN=FRP Authentication Certificate" +cat authorizedClient_cert.pem authorizedClient_key.pem > authorizedClient.pem +openssl pkcs12 -export -in authorizedClient.pem -out authorizedClient.pfx -name "FRP Authentication Certificate" +``` + ### Custom Subdomain Names It is convenient to use `subdomain` configure for http and https types when many people share one frps server. diff --git a/pkg/config/v1/plugin.go b/pkg/config/v1/plugin.go index cdf3cf26..e6bf4fee 100644 --- a/pkg/config/v1/plugin.go +++ b/pkg/config/v1/plugin.go @@ -116,13 +116,14 @@ type HTTPProxyPluginOptions struct { func (o *HTTPProxyPluginOptions) Complete() {} type HTTPS2HTTPPluginOptions struct { - Type string `json:"type,omitempty"` - LocalAddr string `json:"localAddr,omitempty"` - HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"` - RequestHeaders HeaderOperations `json:"requestHeaders,omitempty"` - EnableHTTP2 *bool `json:"enableHTTP2,omitempty"` - CrtPath string `json:"crtPath,omitempty"` - KeyPath string `json:"keyPath,omitempty"` + Type string `json:"type,omitempty"` + LocalAddr string `json:"localAddr,omitempty"` + HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"` + RequestHeaders HeaderOperations `json:"requestHeaders,omitempty"` + EnableHTTP2 *bool `json:"enableHTTP2,omitempty"` + CrtPath string `json:"crtPath,omitempty"` + KeyPath string `json:"keyPath,omitempty"` + ClientCertificates []string `json:"clientCertificates,omitempty"` } func (o *HTTPS2HTTPPluginOptions) Complete() { @@ -130,13 +131,14 @@ func (o *HTTPS2HTTPPluginOptions) Complete() { } type HTTPS2HTTPSPluginOptions struct { - Type string `json:"type,omitempty"` - LocalAddr string `json:"localAddr,omitempty"` - HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"` - RequestHeaders HeaderOperations `json:"requestHeaders,omitempty"` - EnableHTTP2 *bool `json:"enableHTTP2,omitempty"` - CrtPath string `json:"crtPath,omitempty"` - KeyPath string `json:"keyPath,omitempty"` + Type string `json:"type,omitempty"` + LocalAddr string `json:"localAddr,omitempty"` + HostHeaderRewrite string `json:"hostHeaderRewrite,omitempty"` + RequestHeaders HeaderOperations `json:"requestHeaders,omitempty"` + EnableHTTP2 *bool `json:"enableHTTP2,omitempty"` + CrtPath string `json:"crtPath,omitempty"` + KeyPath string `json:"keyPath,omitempty"` + ClientCertificates []string `json:"clientCertificates,omitempty"` } func (o *HTTPS2HTTPSPluginOptions) Complete() { diff --git a/pkg/plugin/client/https2http.go b/pkg/plugin/client/https2http.go index 9632a6fb..e37b2f6b 100644 --- a/pkg/plugin/client/https2http.go +++ b/pkg/plugin/client/https2http.go @@ -19,6 +19,7 @@ package plugin import ( "context" "crypto/tls" + "crypto/x509" "fmt" "io" stdlog "log" @@ -91,6 +92,33 @@ func NewHTTPS2HTTPPlugin(options v1.ClientPluginOptions) (Plugin, error) { return nil, fmt.Errorf("gen TLS config error: %v", err) } + if len(p.opts.ClientCertificates) > 0 { + certs, err := transport.LoadCertificatesFromFiles(p.opts.ClientCertificates) + if err != nil { + return nil, fmt.Errorf("loading Client Certificates failed: %v", err) + } + + tlsConfig.ClientAuth = tls.RequireAnyClientCert + tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + if len(rawCerts) == 0 { + return fmt.Errorf("no client certificate provided") + } + + clientCert, err := x509.ParseCertificate(rawCerts[0]) + if err != nil { + return fmt.Errorf("failed to parse client certificate: %w", err) + } + + for _, allowedCert := range certs { + if clientCert.Equal(allowedCert) { + return nil //match found, accept + } + } + + return fmt.Errorf("client certificate not recognized") + } + } + p.s = &http.Server{ Handler: handler, ReadHeaderTimeout: 60 * time.Second, diff --git a/pkg/plugin/client/https2https.go b/pkg/plugin/client/https2https.go index 8121e094..8e2cc84a 100644 --- a/pkg/plugin/client/https2https.go +++ b/pkg/plugin/client/https2https.go @@ -19,6 +19,7 @@ package plugin import ( "context" "crypto/tls" + "crypto/x509" "fmt" "io" stdlog "log" @@ -97,6 +98,33 @@ func NewHTTPS2HTTPSPlugin(options v1.ClientPluginOptions) (Plugin, error) { return nil, fmt.Errorf("gen TLS config error: %v", err) } + if len(p.opts.ClientCertificates) > 0 { + certs, err := transport.LoadCertificatesFromFiles(p.opts.ClientCertificates) + if err != nil { + return nil, fmt.Errorf("loading Client Certificates failed: %v", err) + } + + tlsConfig.ClientAuth = tls.RequireAnyClientCert + tlsConfig.VerifyPeerCertificate = func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error { + if len(rawCerts) == 0 { + return fmt.Errorf("no client certificate provided") + } + + clientCert, err := x509.ParseCertificate(rawCerts[0]) + if err != nil { + return fmt.Errorf("failed to parse client certificate: %w", err) + } + + for _, allowedCert := range certs { + if clientCert.Equal(allowedCert) { + return nil //match found, accept + } + } + + return fmt.Errorf("client certificate not recognized") + } + } + p.s = &http.Server{ Handler: handler, ReadHeaderTimeout: 60 * time.Second, diff --git a/pkg/transport/tls.go b/pkg/transport/tls.go index 5bc75921..e989c8cc 100644 --- a/pkg/transport/tls.go +++ b/pkg/transport/tls.go @@ -20,6 +20,7 @@ import ( "crypto/tls" "crypto/x509" "encoding/pem" + "fmt" "math/big" "os" ) @@ -140,3 +141,28 @@ func NewRandomPrivateKey() ([]byte, error) { }) return keyPEM, nil } + +func LoadCertificatesFromFiles(certFiles []string) ([]*x509.Certificate, error) { + var certificates []*x509.Certificate + + for _, file := range certFiles { + certPEM, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("failed to read certificate file %s: %w", file, err) + } + + block, _ := pem.Decode(certPEM) + if block == nil { + return nil, fmt.Errorf("failed to decode PEM block") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate file %s: %w", file, err) + } + + certificates = append(certificates, cert) + } + + return certificates, nil +} diff --git a/test/e2e/v1/plugin/client.go b/test/e2e/v1/plugin/client.go index 73e2d863..4a1b5956 100644 --- a/test/e2e/v1/plugin/client.go +++ b/test/e2e/v1/plugin/client.go @@ -236,67 +236,126 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() { Ensure() }) - ginkgo.It("https2http", func() { - generator := &cert.SelfSignedCertGenerator{} - artifacts, err := generator.Generate("example.com") - framework.ExpectNoError(err) - crtPath := f.WriteTempFile("server.crt", string(artifacts.Cert)) - keyPath := f.WriteTempFile("server.key", string(artifacts.Key)) + ginkgo.Describe("https2http", func() { + ginkgo.It("without client certificate requirement", func() { + generator := &cert.SelfSignedCertGenerator{} + artifacts, err := generator.Generate("example.com") + framework.ExpectNoError(err) + crtPath := f.WriteTempFile("server.crt", string(artifacts.Cert)) + keyPath := f.WriteTempFile("server.key", string(artifacts.Key)) - serverConf := consts.DefaultServerConfig - vhostHTTPSPort := f.AllocPort() - serverConf += fmt.Sprintf(` - vhostHTTPSPort = %d - `, vhostHTTPSPort) + serverConf := consts.DefaultServerConfig + vhostHTTPSPort := f.AllocPort() + serverConf += fmt.Sprintf(` + vhostHTTPSPort = %d + `, vhostHTTPSPort) - localPort := f.AllocPort() - clientConf := consts.DefaultClientConfig + fmt.Sprintf(` - [[proxies]] - name = "https2http" - type = "https" - customDomains = ["example.com"] - [proxies.plugin] - type = "https2http" - localAddr = "127.0.0.1:%d" - crtPath = "%s" - keyPath = "%s" - `, localPort, crtPath, keyPath) + localPort := f.AllocPort() + clientConf := consts.DefaultClientConfig + fmt.Sprintf(` + [[proxies]] + name = "https2http" + type = "https" + customDomains = ["example.com"] + [proxies.plugin] + type = "https2http" + localAddr = "127.0.0.1:%d" + crtPath = "%s" + keyPath = "%s" + `, localPort, crtPath, keyPath) - f.RunProcesses([]string{serverConf}, []string{clientConf}) + f.RunProcesses([]string{serverConf}, []string{clientConf}) - localServer := httpserver.New( - httpserver.WithBindPort(localPort), - httpserver.WithResponse([]byte("test")), - ) - f.RunServer("", localServer) + localServer := httpserver.New( + httpserver.WithBindPort(localPort), + httpserver.WithResponse([]byte("test")), + ) + f.RunServer("", localServer) - framework.NewRequestExpect(f). - Port(vhostHTTPSPort). - RequestModify(func(r *request.Request) { - r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{ - ServerName: "example.com", - InsecureSkipVerify: true, - }) - }). - ExpectResp([]byte("test")). - Ensure() + framework.NewRequestExpect(f). + Port(vhostHTTPSPort). + RequestModify(func(r *request.Request) { + r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{ + ServerName: "example.com", + InsecureSkipVerify: true, + }) + }). + ExpectResp([]byte("test")). + Ensure() + }) + + ginkgo.It("with client certificate requirement", func() { + generator := &cert.SelfSignedCertGenerator{} + artifacts, err := generator.Generate("example.com") + framework.ExpectNoError(err) + crtPath := f.WriteTempFile("server.crt", string(artifacts.Cert)) + keyPath := f.WriteTempFile("server.key", string(artifacts.Key)) + + artifacts, err = generator.Generate("127.0.0.1") + framework.ExpectNoError(err) + clientCrtPath := f.WriteTempFile("client.crt", string(artifacts.Cert)) + clientKeyPath := f.WriteTempFile("client.key", string(artifacts.Key)) + + serverConf := consts.DefaultServerConfig + vhostHTTPSPort := f.AllocPort() + serverConf += fmt.Sprintf(` + vhostHTTPSPort = %d + `, vhostHTTPSPort) + + localPort := f.AllocPort() + clientConf := consts.DefaultClientConfig + fmt.Sprintf(` + [[proxies]] + name = "https2http" + type = "https" + customDomains = ["example.com"] + [proxies.plugin] + type = "https2http" + localAddr = "127.0.0.1:%d" + crtPath = "%s" + keyPath = "%s" + clientCertificates = ["%s"] + `, localPort, crtPath, keyPath, clientCrtPath) + + f.RunProcesses([]string{serverConf}, []string{clientConf}) + + localServer := httpserver.New( + httpserver.WithBindPort(localPort), + httpserver.WithResponse([]byte("test")), + ) + f.RunServer("", localServer) + + clientCertificate, err := tls.LoadX509KeyPair(clientCrtPath, clientKeyPath) + framework.ExpectNoError(err) + + framework.NewRequestExpect(f). + Port(vhostHTTPSPort). + RequestModify(func(r *request.Request) { + r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{ + ServerName: "example.com", + InsecureSkipVerify: true, + Certificates: []tls.Certificate{clientCertificate}, + }) + }). + ExpectResp([]byte("test")). + Ensure() + }) }) - ginkgo.It("https2https", func() { - generator := &cert.SelfSignedCertGenerator{} - artifacts, err := generator.Generate("example.com") - framework.ExpectNoError(err) - crtPath := f.WriteTempFile("server.crt", string(artifacts.Cert)) - keyPath := f.WriteTempFile("server.key", string(artifacts.Key)) + ginkgo.Describe("https2https", func() { + ginkgo.It("without client certificate requirement", func() { + generator := &cert.SelfSignedCertGenerator{} + artifacts, err := generator.Generate("example.com") + framework.ExpectNoError(err) + crtPath := f.WriteTempFile("server.crt", string(artifacts.Cert)) + keyPath := f.WriteTempFile("server.key", string(artifacts.Key)) - serverConf := consts.DefaultServerConfig - vhostHTTPSPort := f.AllocPort() - serverConf += fmt.Sprintf(` + serverConf := consts.DefaultServerConfig + vhostHTTPSPort := f.AllocPort() + serverConf += fmt.Sprintf(` vhostHTTPSPort = %d `, vhostHTTPSPort) - localPort := f.AllocPort() - clientConf := consts.DefaultClientConfig + fmt.Sprintf(` + localPort := f.AllocPort() + clientConf := consts.DefaultClientConfig + fmt.Sprintf(` [[proxies]] name = "https2https" type = "https" @@ -308,27 +367,87 @@ var _ = ginkgo.Describe("[Feature: Client-Plugins]", func() { keyPath = "%s" `, localPort, crtPath, keyPath) - f.RunProcesses([]string{serverConf}, []string{clientConf}) + f.RunProcesses([]string{serverConf}, []string{clientConf}) - tlsConfig, err := transport.NewServerTLSConfig("", "", "") - framework.ExpectNoError(err) - localServer := httpserver.New( - httpserver.WithBindPort(localPort), - httpserver.WithResponse([]byte("test")), - httpserver.WithTLSConfig(tlsConfig), - ) - f.RunServer("", localServer) + tlsConfig, err := transport.NewServerTLSConfig("", "", "") + framework.ExpectNoError(err) + localServer := httpserver.New( + httpserver.WithBindPort(localPort), + httpserver.WithResponse([]byte("test")), + httpserver.WithTLSConfig(tlsConfig), + ) + f.RunServer("", localServer) + + framework.NewRequestExpect(f). + Port(vhostHTTPSPort). + RequestModify(func(r *request.Request) { + r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{ + ServerName: "example.com", + InsecureSkipVerify: true, + }) + }). + ExpectResp([]byte("test")). + Ensure() + }) + + ginkgo.It("with client certificate requirement", func() { + generator := &cert.SelfSignedCertGenerator{} + artifacts, err := generator.Generate("example.com") + framework.ExpectNoError(err) + crtPath := f.WriteTempFile("server.crt", string(artifacts.Cert)) + keyPath := f.WriteTempFile("server.key", string(artifacts.Key)) + artifacts, err = generator.Generate("127.0.0.1") + framework.ExpectNoError(err) + clientCrtPath := f.WriteTempFile("client.crt", string(artifacts.Cert)) + clientKeyPath := f.WriteTempFile("client.key", string(artifacts.Key)) + + serverConf := consts.DefaultServerConfig + vhostHTTPSPort := f.AllocPort() + serverConf += fmt.Sprintf(` + vhostHTTPSPort = %d + `, vhostHTTPSPort) + + localPort := f.AllocPort() + clientConf := consts.DefaultClientConfig + fmt.Sprintf(` + [[proxies]] + name = "https2https" + type = "https" + customDomains = ["example.com"] + [proxies.plugin] + type = "https2https" + localAddr = "127.0.0.1:%d" + crtPath = "%s" + keyPath = "%s" + clientCertificates = ["%s"] + `, localPort, crtPath, keyPath, clientCrtPath) + + f.RunProcesses([]string{serverConf}, []string{clientConf}) + + tlsConfig, err := transport.NewServerTLSConfig("", "", "") + framework.ExpectNoError(err) + localServer := httpserver.New( + httpserver.WithBindPort(localPort), + httpserver.WithResponse([]byte("test")), + httpserver.WithTLSConfig(tlsConfig), + ) + f.RunServer("", localServer) + + clientCertificate, err := tls.LoadX509KeyPair(clientCrtPath, clientKeyPath) + framework.ExpectNoError(err) + + framework.NewRequestExpect(f). + Port(vhostHTTPSPort). + RequestModify(func(r *request.Request) { + r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{ + ServerName: "example.com", + InsecureSkipVerify: true, + Certificates: []tls.Certificate{clientCertificate}, + }) + }). + ExpectResp([]byte("test")). + Ensure() + }) - framework.NewRequestExpect(f). - Port(vhostHTTPSPort). - RequestModify(func(r *request.Request) { - r.HTTPS().HTTPHost("example.com").TLSConfig(&tls.Config{ - ServerName: "example.com", - InsecureSkipVerify: true, - }) - }). - ExpectResp([]byte("test")). - Ensure() }) ginkgo.Describe("http2http", func() {