diff --git a/app.go b/app.go
index bda50e89083d8b19bc4823b73f95a637bc74e4ee..f4662a9ef0546cb137931e80293f9ddaf4a17ec3 100644
--- a/app.go
+++ b/app.go
@@ -384,7 +384,7 @@ func runApp(config *cfg.Config) error {
return fmt.Errorf("failed to initialize logging: %w", err)
}
- a.Artifact = artifact.New(config.ArtifactsServer.URL, config.ArtifactsServer.TimeoutSeconds, config.General.Domain)
+ a.Artifact = artifact.New(config.ArtifactsServer.URL, config.ArtifactsServer.TimeoutSeconds, config.General.Domain, config.GitLab.ClientCfg)
if err := a.setAuth(config); err != nil {
return err
@@ -423,6 +423,7 @@ func (a *theApp) setAuth(config *cfg.Config) error {
AuthTimeout: config.Authentication.Timeout,
CookieSessionTimeout: config.Authentication.CookieSessionTimeout,
AllowNamespaceInPath: config.General.NamespaceInPath,
+ ClientCfg: config.GitLab.ClientCfg,
})
if err != nil {
return fmt.Errorf("could not initialize auth package: %w", err)
diff --git a/internal/artifact/artifact.go b/internal/artifact/artifact.go
index de79bc8d8805faa5496809ce356e88f5b524cb11..ec43c455b381c066c111577c290ce9212ee674c2 100644
--- a/internal/artifact/artifact.go
+++ b/internal/artifact/artifact.go
@@ -13,6 +13,7 @@ import (
"strings"
"time"
+ "gitlab.com/gitlab-org/gitlab-pages/internal/config"
"gitlab.com/gitlab-org/gitlab-pages/internal/errortracking"
"gitlab.com/gitlab-org/gitlab-pages/internal/httperrors"
"gitlab.com/gitlab-org/gitlab-pages/internal/httptransport"
@@ -47,13 +48,15 @@ type Artifact struct {
// New when provided the arguments defined herein, returns a pointer to an
// Artifact that is used to proxy requests.
-func New(server string, timeoutSeconds int, pagesDomain string) *Artifact {
+func New(server string, timeoutSeconds int, pagesDomain string, clientCfg config.HTTPClientCfg) *Artifact {
+ httpTransport := httptransport.NewTransportWithClientCert(clientCfg)
+
return &Artifact{
server: strings.TrimRight(server, "/"),
suffix: "." + strings.ToLower(pagesDomain),
client: &http.Client{
Timeout: time.Second * time.Duration(timeoutSeconds),
- Transport: httptransport.DefaultTransport,
+ Transport: httpTransport,
},
}
}
diff --git a/internal/artifact/artifact_test.go b/internal/artifact/artifact_test.go
index 8f40b842b7e23722403e1964bd6daf10667aad7e..167bd5978a229a7bab3f8c8dc159952508e2cec0 100644
--- a/internal/artifact/artifact_test.go
+++ b/internal/artifact/artifact_test.go
@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/require"
"gitlab.com/gitlab-org/gitlab-pages/internal/artifact"
+ "gitlab.com/gitlab-org/gitlab-pages/internal/config"
)
func TestTryMakeRequest(t *testing.T) {
@@ -107,7 +108,7 @@ func TestTryMakeRequest(t *testing.T) {
r.RemoteAddr = c.RemoteAddr
- art := artifact.New(testServer.URL, 1, "gitlab-example.io")
+ art := artifact.New(testServer.URL, 1, "gitlab-example.io", config.HTTPClientCfg{})
result := httptest.NewRecorder()
@@ -301,7 +302,7 @@ func TestBuildURL(t *testing.T) {
for _, c := range cases {
t.Run(c.Description, func(t *testing.T) {
- a := artifact.New(c.RawServer, 1, c.PagesDomain)
+ a := artifact.New(c.RawServer, 1, c.PagesDomain, config.HTTPClientCfg{})
u, ok := a.BuildURL(c.Host, c.Path)
msg := c.Description + " - generated URL: "
@@ -332,7 +333,7 @@ func TestContextCanceled(t *testing.T) {
r = r.WithContext(ctx)
// cancel context explicitly
cancel()
- art := artifact.New(testServer.URL, 1, "gitlab-example.io")
+ art := artifact.New(testServer.URL, 1, "gitlab-example.io", config.HTTPClientCfg{})
require.True(t, art.TryMakeRequest(result, r, "", func(resp *http.Response) bool { return false }))
require.Equal(t, http.StatusNotFound, result.Code)
diff --git a/internal/auth/auth.go b/internal/auth/auth.go
index 7c21cd6b9dd918d4fed39526fa4c287c18ab8a98..210bba030d64cc1fc287473083ec7d98789a9e1f 100644
--- a/internal/auth/auth.go
+++ b/internal/auth/auth.go
@@ -21,6 +21,7 @@ import (
"golang.org/x/crypto/hkdf"
"gitlab.com/gitlab-org/gitlab-pages/internal"
+ "gitlab.com/gitlab-org/gitlab-pages/internal/config"
"gitlab.com/gitlab-org/gitlab-pages/internal/errortracking"
"gitlab.com/gitlab-org/gitlab-pages/internal/feature"
"gitlab.com/gitlab-org/gitlab-pages/internal/httperrors"
@@ -710,6 +711,7 @@ type Options struct {
AuthTimeout time.Duration
CookieSessionTimeout time.Duration
AllowNamespaceInPath bool
+ ClientCfg config.HTTPClientCfg
}
// New when authentication supported this will be used to create authentication handler
@@ -719,6 +721,7 @@ func New(options *Options) (*Auth, error) {
if err != nil {
return nil, err
}
+ httpTransport := httptransport.NewTransportWithClientCert(options.ClientCfg)
return &Auth{
pagesDomain: options.PagesDomain,
@@ -729,7 +732,7 @@ func New(options *Options) (*Auth, error) {
publicGitlabServer: strings.TrimRight(options.PublicGitlabServer, "/"),
apiClient: &http.Client{
Timeout: options.AuthTimeout,
- Transport: httptransport.DefaultTransport,
+ Transport: httpTransport,
},
store: sessions.NewCookieStore(keys[0], keys[1]),
authSecret: options.StoreSecret,
diff --git a/internal/config/config.go b/internal/config/config.go
index ba227016853cc12d4f0cf8449339e195b9c3a6c7..85e26f06bbd0f15b25ce2af33e0713a910d69b36 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -119,6 +119,14 @@ type GitLab struct {
JWTTokenExpiration time.Duration
Cache Cache
EnableDisk bool
+ ClientCfg HTTPClientCfg
+}
+
+type HTTPClientCfg struct {
+ CAFiles []string
+ Certs []tls.Certificate
+ MinVersion uint16
+ MaxVersion uint16
}
// Log groups settings related to configuring logging
@@ -422,6 +430,18 @@ func loadConfig() (*Config, error) {
}
}
+ certs, err := loadClientCerts(clientCerts.Split())
+ if err != nil {
+ return nil, err
+ }
+
+ config.GitLab.ClientCfg = HTTPClientCfg{
+ Certs: certs,
+ CAFiles: caCerts.Split(),
+ MinVersion: allTLSVersions[*tlsMinVersion],
+ MaxVersion: allTLSVersions[*tlsMaxVersion],
+ }
+
customHeaders, err := parseHeaderString(header.Split())
if err != nil {
return nil, fmt.Errorf("unable to parse header string: %w", err)
@@ -449,6 +469,30 @@ func loadConfig() (*Config, error) {
return config, nil
}
+func loadClientCerts(certs []string) ([]tls.Certificate, error) {
+ c := make([]tls.Certificate, 0, len(certs))
+
+ for i, pair := range certs {
+ sep := strings.Index(pair, ":")
+
+ if sep == -1 {
+ return nil, fmt.Errorf("malformed client certs at position %d: %w", i, errMalformedClientCert)
+ }
+
+ cert := pair[:sep]
+ key := pair[sep+1:]
+
+ tlsCert, err := tls.LoadX509KeyPair(cert, key)
+ if err != nil {
+ return nil, err
+ }
+
+ c = append(c, tlsCert)
+ }
+
+ return c, nil
+}
+
func logFields(config *Config) map[string]any {
return map[string]any{
"artifacts-server": config.ArtifactsServer.URL,
@@ -522,6 +566,7 @@ func logFields(config *Config) map[string]any {
"sentry-environment": config.Sentry.Environment,
"version": config.General.ShowVersion,
"namespace-in-path": *namespaceInPath,
+ "ca-certs": config.GitLab.ClientCfg.CAFiles,
}
}
diff --git a/internal/config/flags.go b/internal/config/flags.go
index 069173c3bfbd6e6e78119c1f9bff28469537fa22..3068c602f6e760ce2912701a711f5e4f9742caed 100644
--- a/internal/config/flags.go
+++ b/internal/config/flags.go
@@ -107,6 +107,9 @@ var (
header = MultiStringFlag{separator: ";;"}
+ clientCerts = MultiStringFlag{separator: ","}
+ caCerts = MultiStringFlag{separator: ","}
+
namespaceInPath = flag.Bool("namespace-in-path", false, "Enable Namespace in path")
// flags that won't be logged to the output on Pages boot
@@ -126,6 +129,8 @@ func initFlags() {
flag.Var(&listenHTTPSProxyv2, "listen-https-proxyv2", "The address(es) or unix socket paths to listen on for HTTPS PROXYv2 requests (https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt)")
flag.Var(&header, "header", "The additional http header(s) that should be send to the client")
flag.Var(&tlsClientAuthDomains, "tls-client-auth-domains", "The domain(s) that require client certificate authentication")
+ flag.Var(&clientCerts, "client-cert-key-pairs", "File paths to client certificate and key PEM files to use for mutual TLS")
+ flag.Var(&caCerts, "ca-certs", "File paths to CA certificates used to sign client certificates for mutual TLS")
// read from -config=/path/to/gitlab-pages-config
flag.String(flag.DefaultConfigFlagname, "", "path to config file")
diff --git a/internal/config/validate.go b/internal/config/validate.go
index bb247287c1441bce8b1b370268e5e7531631620a..d572870c541801d52cfe0a6cdcb3db817d31d75a 100644
--- a/internal/config/validate.go
+++ b/internal/config/validate.go
@@ -18,6 +18,7 @@ var (
errArtifactsServerUnsupportedScheme = errors.New("artifacts-server scheme must be either http:// or https://")
errArtifactsServerInvalidTimeout = errors.New("artifacts-server-timeout must be greater than or equal to 1")
errEmptyListener = errors.New("listener must not be empty")
+ errMalformedClientCert = errors.New("client certificates must be defined as /path/to/cert:/path/to/key")
)
// Validate values populated in Config
diff --git a/internal/httptransport/transport.go b/internal/httptransport/transport.go
index 374e39d43edf989ec5920e834ebab8ac97b7de3f..2abffba19673dca4092a04a6269652dce9755f1c 100644
--- a/internal/httptransport/transport.go
+++ b/internal/httptransport/transport.go
@@ -5,10 +5,13 @@ import (
"crypto/x509"
"net"
"net/http"
+ "os"
"sync"
"time"
"gitlab.com/gitlab-org/labkit/log"
+
+ "gitlab.com/gitlab-org/gitlab-pages/internal/config"
)
const (
@@ -53,6 +56,50 @@ func NewTransport() *http.Transport {
}
}
+// NewTransportWithClientCert creates a new http.Transport with the provided client certificate configuration.
+// It sets up the TLS configuration with the provided CA files and client certificates.
+// The transport is configured with default connection values such as timeouts and max idle connections.
+func NewTransportWithClientCert(clientCfg config.HTTPClientCfg) *http.Transport {
+ certPool := pool()
+
+ for _, caFile := range clientCfg.CAFiles {
+ cert, err := os.ReadFile(caFile)
+ if err == nil {
+ certPool.AppendCertsFromPEM(cert)
+ } else {
+ log.WithError(err).WithField("ca-file", caFile).Error("reading CA file")
+ }
+ }
+
+ tlsConfig := &tls.Config{
+ RootCAs: certPool,
+ MinVersion: tls.VersionTLS12, // set MinVersion to fix gosec: G402
+ }
+
+ tlsConfig.MinVersion = clientCfg.MinVersion
+ tlsConfig.MaxVersion = clientCfg.MaxVersion
+
+ if clientCfg.Certs != nil {
+ tlsConfig.Certificates = clientCfg.Certs
+ }
+
+ return &http.Transport{
+ DialTLS: func(network, addr string) (net.Conn, error) {
+ return tls.Dial(network, addr, tlsConfig)
+ },
+ TLSClientConfig: tlsConfig,
+ Proxy: http.ProxyFromEnvironment,
+ // overrides the DefaultMaxIdleConnsPerHost = 2
+ MaxIdleConns: 100,
+ MaxIdleConnsPerHost: 100,
+ IdleConnTimeout: 90 * time.Second,
+ // Set more timeouts https://gitlab.com/gitlab-org/gitlab-pages/-/issues/495
+ TLSHandshakeTimeout: 10 * time.Second,
+ ResponseHeaderTimeout: 15 * time.Second,
+ ExpectContinueTimeout: 15 * time.Second,
+ }
+}
+
// This is here because macOS does not support the SSL_CERT_FILE and
// SSL_CERT_DIR environment variables. We have arranged things to read
// SSL_CERT_FILE and SSL_CERT_DIR as late as possible to avoid conflicts
diff --git a/internal/source/gitlab/client/client.go b/internal/source/gitlab/client/client.go
index a876c73449909f2574aebd3a373d2a87ae3c0cfc..45c78e2d6b3be079d798cc15cd7b324b2089ce17 100644
--- a/internal/source/gitlab/client/client.go
+++ b/internal/source/gitlab/client/client.go
@@ -44,7 +44,7 @@ type Client struct {
// NewClient initializes and returns new Client baseUrl is
// appConfig.InternalGitLabServer secretKey is appConfig.GitLabAPISecretKey
-func NewClient(baseURL string, secretKey []byte, connectionTimeout, jwtTokenExpiry time.Duration) (*Client, error) {
+func NewClient(baseURL string, secretKey []byte, connectionTimeout, jwtTokenExpiry time.Duration, clientCfg config.HTTPClientCfg) (*Client, error) {
if len(baseURL) == 0 || len(secretKey) == 0 {
return nil, errors.New("GitLab API URL or API secret has not been provided")
}
@@ -62,6 +62,8 @@ func NewClient(baseURL string, secretKey []byte, connectionTimeout, jwtTokenExpi
return nil, errors.New("GitLab JWT token expiry has not been provided")
}
+ httpTransport := httptransport.NewTransportWithClientCert(clientCfg)
+
return &Client{
secretKey: secretKey,
baseURL: parsedURL,
@@ -69,7 +71,7 @@ func NewClient(baseURL string, secretKey []byte, connectionTimeout, jwtTokenExpi
Timeout: connectionTimeout,
Transport: httptransport.NewMeteredRoundTripper(
correlation.NewInstrumentedRoundTripper(
- httptransport.DefaultTransport,
+ httpTransport,
correlation.WithClientName(transportClientName),
),
transportClientName,
@@ -85,7 +87,7 @@ func NewClient(baseURL string, secretKey []byte, connectionTimeout, jwtTokenExpi
// NewFromConfig creates a new client from Config struct
func NewFromConfig(cfg *config.GitLab) (*Client, error) {
- return NewClient(cfg.InternalServer, cfg.APISecretKey, cfg.ClientHTTPTimeout, cfg.JWTTokenExpiration)
+ return NewClient(cfg.InternalServer, cfg.APISecretKey, cfg.ClientHTTPTimeout, cfg.JWTTokenExpiration, cfg.ClientCfg)
}
// Resolve returns a VirtualDomain configuration wrapped into a Lookup for a
diff --git a/internal/source/gitlab/client/client_test.go b/internal/source/gitlab/client/client_test.go
index ca7b21fb3f200b2b54ca47f12f9d8c9fbc480ede..d14a02c01041f05b841883db9f2cee46b505219d 100644
--- a/internal/source/gitlab/client/client_test.go
+++ b/internal/source/gitlab/client/client_test.go
@@ -2,6 +2,7 @@ package client
import (
"context"
+ "crypto/tls"
"encoding/base64"
"fmt"
"net/http"
@@ -14,8 +15,10 @@ import (
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/require"
+ "gitlab.com/gitlab-org/gitlab-pages/internal/config"
"gitlab.com/gitlab-org/gitlab-pages/internal/domain"
"gitlab.com/gitlab-org/gitlab-pages/internal/fixture"
+ "gitlab.com/gitlab-org/gitlab-pages/internal/testhelpers"
)
const (
@@ -59,7 +62,7 @@ func TestConnectionReuse(t *testing.T) {
}
func TestNewValidBaseURL(t *testing.T) {
- _, err := NewClient("https://gitlab.com", secretKey(t), defaultClientConnTimeout, defaultJWTTokenExpiry)
+ _, err := NewClient("https://gitlab.com", secretKey(t), defaultClientConnTimeout, defaultJWTTokenExpiry, config.HTTPClientCfg{})
require.NoError(t, err)
}
@@ -129,7 +132,7 @@ func TestNewInvalidConfiguration(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- got, err := NewClient(tt.args.baseURL, tt.args.secretKey, tt.args.connectionTimeout, tt.args.jwtTokenExpiry)
+ got, err := NewClient(tt.args.baseURL, tt.args.secretKey, tt.args.connectionTimeout, tt.args.jwtTokenExpiry, config.HTTPClientCfg{})
require.Nil(t, got)
require.Error(t, err)
require.Contains(t, err.Error(), tt.wantErrMsg)
@@ -233,6 +236,49 @@ func TestGetVirtualDomainAuthenticatedRequest(t *testing.T) {
require.Equal(t, "mygroup/myproject/public/", lookupPath.Source.Path)
}
+func TestMutualTLSClientAuthentication(t *testing.T) {
+ gitlabMux := http.NewServeMux()
+ gitlabMux.HandleFunc("/api/v4/internal/pages", func(w http.ResponseWriter, r *http.Request) {
+ // Ensure that pagesClient certificate is present
+ if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
+ w.WriteHeader(http.StatusForbidden)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+ })
+
+ certPath := "../../../../test/testdata/mutualtls/valid/client.crt"
+ keyPath := "../../../../test/testdata/mutualtls/valid/client.key"
+ caCertPath := "../../../../test/testdata/mutualtls/valid/ca.crt"
+
+ tlsCfg := &tls.Config{
+ ClientCAs: testhelpers.CertPool(t, caCertPath),
+ ClientAuth: tls.RequireAndVerifyClientCert,
+ Certificates: []tls.Certificate{testhelpers.Cert(t, certPath, keyPath)},
+ MinVersion: tls.VersionTLS12,
+ }
+
+ server := httptest.NewUnstartedServer(gitlabMux)
+ server.TLS = tlsCfg
+ server.StartTLS()
+ defer server.Close()
+
+ hcc := config.HTTPClientCfg{Certs: []tls.Certificate{testhelpers.Cert(t, certPath, keyPath)}, CAFiles: []string{caCertPath}}
+
+ pagesClient, err := NewClient(server.URL, secretKey(t), defaultClientConnTimeout, defaultJWTTokenExpiry, hcc)
+ require.NoError(t, err)
+
+ params := url.Values{}
+ params.Set("host", "group.gitlab.io")
+
+ resp, err := pagesClient.get(context.Background(), "/api/v4/internal/pages", params)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+}
+
func validateToken(t *testing.T, tokenString string) {
t.Helper()
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
@@ -263,7 +309,7 @@ func secretKey(t *testing.T) []byte {
func defaultClient(t *testing.T, url string) *Client {
t.Helper()
- client, err := NewClient(url, secretKey(t), defaultClientConnTimeout, defaultJWTTokenExpiry)
+ client, err := NewClient(url, secretKey(t), defaultClientConnTimeout, defaultJWTTokenExpiry, config.HTTPClientCfg{})
require.NoError(t, err)
return client
@@ -324,7 +370,7 @@ func Test_endpoint(t *testing.T) {
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
- gc, err := NewClient(tt.basePath, []byte("secret"), defaultClientConnTimeout, defaultJWTTokenExpiry)
+ gc, err := NewClient(tt.basePath, []byte("secret"), defaultClientConnTimeout, defaultJWTTokenExpiry, config.HTTPClientCfg{})
require.NoError(t, err)
got, err := gc.endpoint(tt.urlPath, tt.params)
diff --git a/internal/testhelpers/testhelpers.go b/internal/testhelpers/testhelpers.go
index 1bb537cddde7412d8730a0a2361e092e4aa3c988..f6d5f062c4bb4ff2b51af067b5f96e6470a18037 100644
--- a/internal/testhelpers/testhelpers.go
+++ b/internal/testhelpers/testhelpers.go
@@ -1,6 +1,8 @@
package testhelpers
import (
+ "crypto/tls"
+ "crypto/x509"
"fmt"
"io"
"net/http"
@@ -110,3 +112,32 @@ func Close(t *testing.T, c io.Closer) {
require.NoError(t, c.Close())
})
}
+
+// CertPool creates a new certificate pool containing the certificate.
+func CertPool(tb testing.TB, certPath string) *x509.CertPool {
+ tb.Helper()
+ pem := MustReadFile(tb, certPath)
+ pool := x509.NewCertPool()
+ require.True(tb, pool.AppendCertsFromPEM(pem))
+ return pool
+}
+
+// Cert returns the parsed certificate.
+func Cert(tb testing.TB, certPath, keyPath string) tls.Certificate {
+ tb.Helper()
+ cert, err := tls.LoadX509KeyPair(certPath, keyPath)
+ require.NoError(tb, err)
+ return cert
+}
+
+// MustReadFile returns the content of a file or fails at once.
+func MustReadFile(tb testing.TB, filename string) []byte {
+ tb.Helper()
+
+ content, err := os.ReadFile(filename)
+ if err != nil {
+ tb.Fatal(err)
+ }
+
+ return content
+}
diff --git a/test/acceptance/artifacts_test.go b/test/acceptance/artifacts_test.go
index c88f48bf4ed438f2ab9e312c6241d7588df65dd2..6a58c80095901391589f04a7887f58d97ecaf1f0 100644
--- a/test/acceptance/artifacts_test.go
+++ b/test/acceptance/artifacts_test.go
@@ -266,3 +266,161 @@ func TestPrivateArtifactProxyRequest(t *testing.T) {
})
}
}
+
+type testCase struct {
+ name string
+ host string
+ path string
+ status int
+ content string
+ length int64
+ cacheControl string
+ contentType string
+}
+
+func testArtifactProxyRequestWithMTLS(t *testing.T, content string, tests []testCase, clientCertPath, clientKeyPath, caCertPath string) {
+ t.Helper()
+
+ testServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.RawPath {
+ case "/api/v4/projects/group%2Fproject/jobs/1/artifacts/200.html",
+ "/api/v4/projects/group%2Fsubgroup%2Fproject/jobs/1/artifacts/200.html":
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ fmt.Fprint(w, content)
+ default:
+ t.Logf("Unexpected r.URL.RawPath: %q", r.URL.RawPath)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusNotFound)
+ fmt.Fprint(w, content)
+ }
+ }))
+
+ keyFile, certFile := CreateHTTPSFixtureFiles(t)
+ serverCert, err := tls.LoadX509KeyPair(certFile, keyFile)
+ require.NoError(t, err)
+
+ tlsCfg := &tls.Config{
+ ClientCAs: testhelpers.CertPool(t, caCertPath),
+ ClientAuth: tls.RequireAndVerifyClientCert,
+ Certificates: []tls.Certificate{serverCert, testhelpers.Cert(t, clientCertPath, clientKeyPath)},
+ MinVersion: tls.VersionTLS12,
+ }
+
+ testServer.TLS = tlsCfg
+ testServer.StartTLS()
+
+ t.Cleanup(func() {
+ testServer.Close()
+ })
+
+ // Ensure the IP address is used in the URL, as we're relying on IP SANs to
+ // validate
+ artifactServerURL := testServer.URL + "/api/v4"
+ t.Log("Artifact server URL", artifactServerURL)
+
+ args := []string{"-artifacts-server=" + artifactServerURL, "-artifacts-server-timeout=1"}
+
+ t.Setenv("SSL_CERT_FILE", certFile)
+
+ clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
+ require.NoError(t, err)
+
+ caCert, err := loadCACertificate(caCertPath)
+ require.NoError(t, err)
+
+ RunPagesProcess(t,
+ withListeners([]ListenSpec{httpListener}),
+ withArguments(args),
+ withExtraArgument("client-cert-key-pairs", clientCertPath+":"+clientKeyPath),
+ withExtraArgument("ca-certs", caCertPath),
+ withStubOptions(gitlabstub.WithCertificate(clientCert), gitlabstub.WithMutualTLS(caCert)),
+ )
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ resp, err := GetPageFromListener(t, httpListener, tt.host, tt.path)
+ require.NoError(t, err)
+ testhelpers.Close(t, resp.Body)
+
+ require.Equal(t, tt.status, resp.StatusCode)
+ require.Equal(t, tt.contentType, resp.Header.Get("Content-Type"))
+
+ if tt.status == http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ require.Equal(t, tt.content, string(body))
+ require.Equal(t, tt.length, resp.ContentLength)
+ require.Equal(t, tt.cacheControl, resp.Header.Get("Cache-Control"))
+ }
+ })
+ }
+}
+
+func TestArtifactProxyRequestWithValidMTLS(t *testing.T) {
+ content := "
Title of the document"
+ contentLength := int64(len(content))
+
+ clientCertPath := "../../test/testdata/mutualtls/valid/client.crt"
+ clientKeyPath := "../../test/testdata/mutualtls/valid/client.key"
+ caCertPath := "../../test/testdata/mutualtls/valid/ca.crt"
+
+ tests := []testCase{
+ {
+ name: "basic proxied request",
+ host: "group.gitlab-example.com",
+ path: "/-/project/-/jobs/1/artifacts/200.html",
+ status: http.StatusOK,
+ content: content,
+ length: contentLength,
+ cacheControl: "max-age=3600",
+ contentType: "text/html; charset=utf-8",
+ },
+ {
+ name: "basic proxied request for subgroup",
+ host: "group.gitlab-example.com",
+ path: "/-/subgroup/project/-/jobs/1/artifacts/200.html",
+ status: http.StatusOK,
+ content: content,
+ length: contentLength,
+ cacheControl: "max-age=3600",
+ contentType: "text/html; charset=utf-8",
+ },
+ }
+
+ testArtifactProxyRequestWithMTLS(t, content, tests, clientCertPath, clientKeyPath, caCertPath)
+}
+
+func TestArtifactProxyRequestWithInvalidMTLS(t *testing.T) {
+ content := "Title of the document"
+
+ clientCertPath := "../../test/testdata/mutualtls/invalid/client.crt"
+ clientKeyPath := "../../test/testdata/mutualtls/invalid/client.key"
+ caCertPath := "../../test/testdata/mutualtls/invalid/ca.crt"
+
+ tests := []testCase{
+ {
+ name: "basic proxied request",
+ host: "group.gitlab-example.com",
+ path: "/-/project/-/jobs/1/artifacts/200.html",
+ status: http.StatusBadGateway,
+ content: "",
+ length: 0,
+ cacheControl: "",
+ contentType: "text/html; charset=utf-8",
+ },
+ {
+ name: "basic proxied request for subgroup",
+ host: "group.gitlab-example.com",
+ path: "/-/subgroup/project/-/jobs/1/artifacts/200.html",
+ status: http.StatusBadGateway,
+ content: "",
+ length: 0,
+ cacheControl: "",
+ contentType: "text/html; charset=utf-8",
+ },
+ }
+ testArtifactProxyRequestWithMTLS(t, content, tests, clientCertPath, clientKeyPath, caCertPath)
+}
diff --git a/test/acceptance/auth_test.go b/test/acceptance/auth_test.go
index 022afcbd1f1088dc151fc28b4659baa88f34a98c..c6e8f871e4cd644e8b780a78c8deca32c27377a7 100644
--- a/test/acceptance/auth_test.go
+++ b/test/acceptance/auth_test.go
@@ -635,6 +635,56 @@ func TestHijackedCode(t *testing.T) {
require.Equal(t, impersonatingRes.StatusCode, http.StatusInternalServerError, "should fail to decode code")
}
+func TestWhenAuthIsEnabledPrivateWillRedirectToAuthorizeWithValidMTLS(t *testing.T) {
+ clientCertPath := "../../test/testdata/mutualtls/valid/client.crt"
+ clientKeyPath := "../../test/testdata/mutualtls/valid/client.key"
+ caCertPath := "../../test/testdata/mutualtls/valid/ca.crt"
+
+ RunPagesProcessWithMutualTLS(t, httpsListener, defaultAuthConfig(t), clientCertPath, clientKeyPath, caCertPath)
+
+ rsp, err := GetRedirectPage(t, httpsListener, "group.auth.gitlab-example.com", "private.project/")
+
+ require.NoError(t, err)
+ testhelpers.Close(t, rsp.Body)
+
+ require.Equal(t, http.StatusFound, rsp.StatusCode)
+ require.Len(t, rsp.Header["Location"], 1)
+ url, err := url.Parse(rsp.Header.Get("Location"))
+ require.NoError(t, err)
+ rsp, err = GetRedirectPage(t, httpsListener, url.Host, url.Path+"?"+url.RawQuery)
+ require.NoError(t, err)
+ testhelpers.Close(t, rsp.Body)
+
+ require.Equal(t, http.StatusFound, rsp.StatusCode)
+ require.Len(t, rsp.Header["Location"], 1)
+
+ url, err = url.Parse(rsp.Header.Get("Location"))
+ require.NoError(t, err)
+
+ require.Equal(t, "https", url.Scheme)
+ require.Equal(t, "public-gitlab-auth.com", url.Host)
+ require.Equal(t, "/oauth/authorize", url.Path)
+ require.Equal(t, "clientID", url.Query().Get("client_id"))
+ require.Equal(t, "https://projects.gitlab-example.com/auth", url.Query().Get("redirect_uri"))
+ require.NotEmpty(t, url.Query().Get("scope"))
+ require.NotEmpty(t, url.Query().Get("state"))
+}
+
+func TestWhenAuthIsEnabledPrivateWillRedirectToAuthorizeWithInvalidMTLS(t *testing.T) {
+ clientCertPath := "../../test/testdata/mutualtls/invalid/client.crt"
+ clientKeyPath := "../../test/testdata/mutualtls/invalid/client.key"
+ caCertPath := "../../test/testdata/mutualtls/invalid/ca.crt"
+
+ RunPagesProcessWithMutualTLS(t, httpsListener, defaultAuthConfig(t), clientCertPath, clientKeyPath, caCertPath)
+
+ rsp, err := GetRedirectPage(t, httpsListener, "group.auth.gitlab-example.com", "private.project/")
+
+ require.NoError(t, err)
+ testhelpers.Close(t, rsp.Body)
+
+ require.Equal(t, http.StatusBadGateway, rsp.StatusCode)
+}
+
func getValidCookieAndState(t *testing.T, domain string) (string, string) {
t.Helper()
diff --git a/test/acceptance/gitlab_api_mutual_tls_test.go b/test/acceptance/gitlab_api_mutual_tls_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..151e5e830c2d931a7eb10d9425b1b46d31b6f2e3
--- /dev/null
+++ b/test/acceptance/gitlab_api_mutual_tls_test.go
@@ -0,0 +1,59 @@
+package acceptance_test
+
+import (
+ _ "embed"
+ "net/http"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestGitLabAPIMutualTLS(t *testing.T) {
+ tests := []struct {
+ name string
+ host string
+ path string
+ clientCertPath string
+ clientKeyPath string
+ caCertPath string
+ status int
+ expectError bool
+ }{
+ {
+ name: "basic request works with GitLab API mutual TLS",
+ host: "group.gitlab-example.com",
+ path: "/index.html",
+ clientCertPath: "../../test/testdata/mutualtls/valid/client.crt",
+ clientKeyPath: "../../test/testdata/mutualtls/valid/client.key",
+ caCertPath: "../../test/testdata/mutualtls/valid/ca.crt",
+ status: http.StatusOK,
+ expectError: false,
+ },
+ {
+ name: "502 when invalid mutual TLS is used",
+ host: "group.gitlab-example.com",
+ path: "/index.html",
+ clientCertPath: "../../test/testdata/mutualtls/invalid/client.crt",
+ clientKeyPath: "../../test/testdata/mutualtls/invalid/client.key",
+ caCertPath: "../../test/testdata/mutualtls/invalid/ca.crt",
+ status: http.StatusBadGateway,
+ expectError: false,
+ },
+ }
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ RunPagesProcessWithMutualTLS(t, httpsListener, "", tt.clientCertPath, tt.clientKeyPath, tt.caCertPath)
+ rsp, err := GetPageFromListener(t, httpsListener, tt.host, tt.path)
+
+ if tt.expectError {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ rsp.Body.Close()
+ }
+ require.Equal(t, tt.status, rsp.StatusCode)
+ })
+ }
+}
diff --git a/test/acceptance/helpers_test.go b/test/acceptance/helpers_test.go
index d089c0913472855edef3ffbfe44f6eb4f9e8caf3..f3b02f1c0cbf56ddaf9c8ee1582b937f85a2b6cd 100644
--- a/test/acceptance/helpers_test.go
+++ b/test/acceptance/helpers_test.go
@@ -5,6 +5,7 @@ import (
"context"
"crypto/tls"
"crypto/x509"
+ "encoding/pem"
"fmt"
"io"
"net"
@@ -334,6 +335,48 @@ func RunPagesProcessWithSSLCertDir(t *testing.T, listeners []ListenSpec, sslCert
)
}
+func RunPagesProcessWithMutualTLS(t *testing.T, listener ListenSpec, configFile string, clientCertPath, clientKeyPath, caCertPath string) {
+ clientCert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath)
+ require.NoError(t, err)
+
+ caCert, err := loadCACertificate(caCertPath)
+ require.NoError(t, err)
+
+ if configFile == "" {
+ configFile = newConfigFile(t)
+ }
+
+ RunPagesProcess(t,
+ withListeners([]ListenSpec{listener}),
+ withArguments([]string{
+ "-config=" + configFile,
+ }),
+ withExtraArgument("client-cert-key-pairs", clientCertPath+":"+clientKeyPath),
+ withExtraArgument("ca-certs", caCertPath),
+ withStubOptions(gitlabstub.WithCertificate(clientCert), gitlabstub.WithMutualTLS(caCert)),
+ )
+}
+
+func loadCACertificate(certPath string) (*x509.Certificate, error) {
+ if certPath == "" {
+ return nil, nil
+ }
+
+ caCertFile, err := os.ReadFile(certPath)
+ if err != nil {
+ return nil, fmt.Errorf("error reading CA file: %w", err)
+ }
+
+ block, _ := pem.Decode(caCertFile)
+
+ caCert, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("error parsing CA certificate: %w", err)
+ }
+
+ return caCert, nil
+}
+
func runPagesProcess(t *testing.T, wait bool, pagesBinary string, listeners []ListenSpec, promPort string, extraArgs ...string) (*LogCaptureBuffer, func()) {
t.Helper()
diff --git a/test/gitlabstub/cmd/server/main.go b/test/gitlabstub/cmd/server/main.go
index 9820b72204b78627ea4573c901b81049e9b0b746..46cf665f323ed260501b26b9e996c8125db742b5 100644
--- a/test/gitlabstub/cmd/server/main.go
+++ b/test/gitlabstub/cmd/server/main.go
@@ -3,12 +3,17 @@ package main
import (
"context"
"crypto/tls"
+ "crypto/x509"
+ "encoding/pem"
"errors"
"flag"
+ "fmt"
"log"
"net/http"
+ "net/http/httptest"
"os"
"os/signal"
+ "path/filepath"
"syscall"
"time"
@@ -16,40 +21,24 @@ import (
)
var (
- pagesRoot = flag.String("pages-root", "shared/pages", "The directory where pages are stored")
- keyFile = flag.String("key-file", "", "Path to file certificate")
- certFile = flag.String("cert-file", "", "Path to file certificate")
+ pagesRoot = flag.String("pages-root", "shared/pages", "The directory where pages are stored")
+ keyFile = flag.String("key-file", "", "Path to file certificate")
+ certFile = flag.String("cert-file", "", "Path to file certificate")
+ caCertPath = flag.String("ca-crt", "", "CA certificate for mutual TLS")
)
func main() {
flag.Parse()
- var opts []gitlabstub.Option
-
- if *keyFile != "" && *certFile != "" {
- log.Printf("Loading key pair: (%s) - (%s)", *certFile, *keyFile)
- cert, err := tls.LoadX509KeyPair(*certFile, *keyFile)
- if err != nil {
- log.Fatalf("error loading certificate: %v", err)
- }
-
- opts = append(opts, gitlabstub.WithCertificate(cert))
- }
-
- if err := os.Chdir(*pagesRoot); err != nil {
- log.Fatalf("error chdir in %s: %v", *pagesRoot, err)
+ if err := run(); err != nil {
+ log.Fatal(err)
}
+}
- wd, err := os.Getwd()
+func run() error {
+ server, err := createServer()
if err != nil {
- log.Fatalf("error getting current dir: %v", err)
- }
-
- opts = append(opts, gitlabstub.WithPagesRoot(wd))
-
- server, err := gitlabstub.NewUnstartedServer(opts...)
- if err != nil {
- log.Fatalf("error starting the server: %v", err)
+ return fmt.Errorf("error starting the server: %w", err)
}
if server.TLS != nil {
@@ -69,6 +58,73 @@ func main() {
defer cancel()
if err := server.Config.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
- log.Fatalf("error shutting down %v", err)
+ return fmt.Errorf("error shutting down %w", err)
+ }
+
+ return nil
+}
+
+func createServer() (*httptest.Server, error) {
+ var opts []gitlabstub.Option
+
+ if cert, err := loadCertificate(*certFile, *keyFile); err != nil {
+ return nil, err
+ } else if cert != nil {
+ opts = append(opts, gitlabstub.WithCertificate(*cert))
+ }
+
+ if wd, err := filepath.Abs(*pagesRoot); err != nil {
+ return nil, err
+ } else if *pagesRoot != "" {
+ opts = append(opts, gitlabstub.WithPagesRoot(wd))
}
+
+ if caCert, err := loadCACertificate(*caCertPath); err != nil {
+ return nil, err
+ } else if caCert != nil {
+ opts = append(opts, gitlabstub.WithMutualTLS(caCert))
+ }
+
+ server, err := gitlabstub.NewUnstartedServer(opts...)
+ if err != nil {
+ return nil, fmt.Errorf("error starting the server: %w", err)
+ }
+
+ return server, err
+}
+
+func loadCertificate(cert string, key string) (*tls.Certificate, error) {
+ if cert == "" && key == "" {
+ return nil, nil
+ }
+
+ if cert != "" && key != "" {
+ cert, err := tls.LoadX509KeyPair(*certFile, *keyFile)
+ if err != nil {
+ return nil, fmt.Errorf("error loading certificate: %w", err)
+ }
+
+ return &cert, nil
+ }
+
+ return nil, fmt.Errorf("missing certificate or key: cert(%s) key(%s)", cert, key)
+}
+func loadCACertificate(certPath string) (*x509.Certificate, error) {
+ if certPath == "" {
+ return nil, nil
+ }
+
+ caCertFile, err := os.ReadFile(certPath)
+ if err != nil {
+ return nil, fmt.Errorf("error reading CA file: %w", err)
+ }
+
+ block, _ := pem.Decode(caCertFile)
+
+ caCert, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ return nil, fmt.Errorf("error parsing CA certificate: %w", err)
+ }
+
+ return caCert, nil
}
diff --git a/test/gitlabstub/option.go b/test/gitlabstub/option.go
index 366aeb5db81ee3742b24cd5d8ae7c1d22488ffa5..3057fe00bd39e8586931052d491e5161cb35932c 100644
--- a/test/gitlabstub/option.go
+++ b/test/gitlabstub/option.go
@@ -2,6 +2,7 @@ package gitlabstub
import (
"crypto/tls"
+ "crypto/x509"
"net/http"
"time"
)
@@ -40,10 +41,22 @@ func WithDelay(delay time.Duration) Option {
}
func WithCertificate(cert tls.Certificate) Option {
- return func(c *config) {
- if c.tlsConfig == nil {
- c.tlsConfig = defaultTLSConfig()
+ return func(sc *config) {
+ if sc.tlsConfig == nil {
+ sc.tlsConfig = defaultTLSConfig()
}
- c.tlsConfig.Certificates = append(c.tlsConfig.Certificates, cert)
+ sc.tlsConfig.Certificates = append(sc.tlsConfig.Certificates, cert)
+ }
+}
+
+func WithMutualTLS(caCert *x509.Certificate) Option {
+ return func(sc *config) {
+ if sc.tlsConfig == nil {
+ sc.tlsConfig = defaultTLSConfig()
+ }
+
+ sc.tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
+ sc.tlsConfig.ClientCAs = x509.NewCertPool()
+ sc.tlsConfig.ClientCAs.AddCert(caCert)
}
}
diff --git a/test/testdata/mutualtls/invalid/ca.crt b/test/testdata/mutualtls/invalid/ca.crt
new file mode 100644
index 0000000000000000000000000000000000000000..5ddf7b1e310738cd804ee29161ee07ad9b9f3860
--- /dev/null
+++ b/test/testdata/mutualtls/invalid/ca.crt
@@ -0,0 +1,33 @@
+-----BEGIN CERTIFICATE-----
+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
+-----END CERTIFICATE-----
diff --git a/test/testdata/mutualtls/invalid/client.crt b/test/testdata/mutualtls/invalid/client.crt
new file mode 100644
index 0000000000000000000000000000000000000000..069624ff27667487b5e0e324e355f3259d2b22f7
--- /dev/null
+++ b/test/testdata/mutualtls/invalid/client.crt
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIIDxDCCAawCFByFBHU3RaAIgFvtrNvhHMhgqGCUMA0GCSqGSIb3DQEBCwUAMHIx
+CzAJBgNVBAYTAlVTMQ0wCwYDVQQIDARUZXN0MRUwEwYDVQQHDAxEZWZhdWx0IENp
+dHkxHDAaBgNVBAoME0RlZmF1bHQgQ29tcGFueSBMdGQxDTALBgNVBAsMBFRlc3Qx
+EDAOBgNVBAMMB1Rlc3QgQ0EwHhcNMjMwOTIwMTEzNDM3WhcNMzMwOTE3MTEzNDM3
+WjCBlTELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxFTATBgNVBAcMDERlZmF1
+bHQgQ2l0eTEcMBoGA1UECgwTRGVmYXVsdCBDb21wYW55IEx0ZDENMAsGA1UECwwE
+VGVzdDESMBAGA1UEAwwJVGVzdCBVc2VyMR8wHQYJKoZIhvcNAQkBFhB0ZXN0QGV4
+YW1wbGUub3JnMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE5S6e7bNszpyhc279
+oRgW8TMytl0IGaPIhMoDF4oUXO+/sOK/cCz59II7nkwkk6COrvbh70ft8e9cdsqt
+a5UsvzANBgkqhkiG9w0BAQsFAAOCAgEAFGDKm6825SRYPTi7HG6uK1S1CXbPNnSy
+hv9umTa1iRLqip9eacNx6uijdRQr2IOOBZfuRAjut9IYlH/PfrNYPVcc/uYH/gIP
+LZdVjMxqpNxqQYUup3Wq8mVu8Gnfbm2ymvCPHnUhM4xH++grmUQW5bq7BozL/oea
+AfflJeS3AGmqPBNBDvjubn7NCirzKEkrt7COAXnpXmFmiErT9gWkeE1Xtn51/W3b
+27lu+5aBzDUtlppa5eT1GPc6fu5+HLtHod+nDn/7Ys/HUWw/5iA6DRhGXnLlz2W7
+C+m1yDtSVH+axhAXbhnzvZUpB6jXLLce99a5ff84fp/nJ96hR5lHekIkfNj0wtrQ
+WqvQ9HuU1Q3dbWOpC9xtOvQBINAnsZGrM8XwLzG39WdK2G9mLNQqHqKedCWW9UhE
+lsOZrYihuXOmTBCIW+SGDw2z4unsFdNMXXwJVv8REDJzGmq5JXai40nnnemS7JNQ
+ztgO1gPnznZQFsbHgvSSJOoi5sz/o2VeJJ+zYzPSc7Hzbm/MPlcOdXo2uLO6JrIN
+3Kk9Gdv/6tQ+gVUq3qNS/1cmiVTyNiiSOpLM1qLPVL/n5HFYPTbI3OQxRomkMb5j
+ab5Oj4+trDQQ1ysm6i8UEqQoCr/izj+2muIGp7u94UrcRmJkULwpjkRsQD/uX5yd
+hNo/s+Sx0hA=
+-----END CERTIFICATE-----
diff --git a/test/testdata/mutualtls/invalid/client.key b/test/testdata/mutualtls/invalid/client.key
new file mode 100644
index 0000000000000000000000000000000000000000..17a6bff88f979afb507f6bcf33804c3b8e67063c
--- /dev/null
+++ b/test/testdata/mutualtls/invalid/client.key
@@ -0,0 +1,5 @@
+-----BEGIN EC PRIVATE KEY-----
+MHcCAQEEIFWzobEUkcIgOUR81lz2KSmoPKyNI5UraogKlWOESRbHoAoGCCqGSM49
+AwEHoUQDQgAE5S6e7bNszpyhc279oRgW8TMytl0IGaPIhMoDF4oUXO+/sOK/cCz5
+9II7nkwkk6COrvbh70ft8e9cdsqta5Usvw==
+-----END EC PRIVATE KEY-----
diff --git a/test/testdata/mutualtls/valid/README.md b/test/testdata/mutualtls/valid/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..1ad56fc50dd0b33622e781390af0785c676734e9
--- /dev/null
+++ b/test/testdata/mutualtls/valid/README.md
@@ -0,0 +1,15 @@
+## Steps to generate certificates in terminal for mutual TLS:
+
+### Generate CA certificate:
+```
+openssl genrsa -out ca.key 2048
+openssl req -new -x509 -days 9999 -key ca.key -subj "/C=CN/ST=GD/L=SZ/O=GitLab, Inc./CN=localhost" -out ca.crt
+```
+
+### Generate client certificate and sign using above generated CA certificate:
+```
+echo "subjectAltName = DNS:localhost,IP:127.0.0.1" > test.txt
+
+openssl req -newkey rsa:2048 -nodes -keyout client.key -subj "/C=CN/ST=GD/L=SZ/O=GitLab, Inc./CN=localhost" -out client.csr
+openssl x509 -req -extfile test.txt -days 365 -in client.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out client.crt
+```
\ No newline at end of file
diff --git a/test/testdata/mutualtls/valid/ca.crt b/test/testdata/mutualtls/valid/ca.crt
new file mode 100644
index 0000000000000000000000000000000000000000..a2e52b722b8339ea16cf3fb3f5485b243fb6d9f6
--- /dev/null
+++ b/test/testdata/mutualtls/valid/ca.crt
@@ -0,0 +1,21 @@
+-----BEGIN CERTIFICATE-----
+MIIDhzCCAm+gAwIBAgIUfCgs0bsZePJLm7rGA4p2DXWpVNMwDQYJKoZIhvcNAQEL
+BQAwUjELMAkGA1UEBhMCQ04xCzAJBgNVBAgMAkdEMQswCQYDVQQHDAJTWjEVMBMG
+A1UECgwMR2l0TGFiLCBJbmMuMRIwEAYDVQQDDAlsb2NhbGhvc3QwIBcNMjQwNTEz
+MDc1MzAxWhgPMjA1MTA5MjgwNzUzMDFaMFIxCzAJBgNVBAYTAkNOMQswCQYDVQQI
+DAJHRDELMAkGA1UEBwwCU1oxFTATBgNVBAoMDEdpdExhYiwgSW5jLjESMBAGA1UE
+AwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoJ4T
+OzEIw70l2EQkrpEmvUpWy0JNkotW9fsrWdgg+nbC+vj/s/7rd38crWU3ygYYlfrz
+CeWg4ogwu5dv+HdvIsNKkORurYMlME5FMNUdRN9kzdoNwxHfiA1pVmnFg9O5uLAk
+8+r8qgPSPIjDFoAOPl+WTaseQVXeZZHNbrNaSXuWFxdYlbSl5Ek8isWWxYSho+dI
+zXoeOmhmh503IDpIfdE0bkeX7SJWhYAs8wIkGpX4Fkaeyq5SKyNv4IHibDyugtv8
+Ye9sYFLKvZytiry6y7kO8m/CDFyMpVBh3/lG5wayxUBDRWxyr8RTn3YwlvKY/iM3
+bUy4/6HPceKbzCUUbwIDAQABo1MwUTAdBgNVHQ4EFgQUtVgxsOKDyxnLpuuhg5+H
+O7UGit4wHwYDVR0jBBgwFoAUtVgxsOKDyxnLpuuhg5+HO7UGit4wDwYDVR0TAQH/
+BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAb4V74iywZNhn76D9moBPR+6nNprf
+HhX4uVwza1bKsIIDpTqgchAmsuo5b7RYs29Qi9nLXDKh3IxTnf0l63x6c+At/aWC
+XTcC1x9+XZKuF/J+eoQQMPQdo4o24F/1Lu1cJnWbc9hHlxiuqrB2t3eSc5gffQRL
+jJKGz+geTClxuMV23D6BugbJksom+JCVhWp5MRQFI4y+sDpVPgxm6AdgVAXHbYr/
+uFTiZ63PILrodaHXCJKYIR50p48fyiMqhOa2sqVAQ5qIh1C05K1JVpUlVDnZRT7E
+Y+Dx9ZI8NlXpHxfiFKxcz5aCPjqZEelqAuroQiCZ8b+rErsqcV57z5ArOw==
+-----END CERTIFICATE-----
diff --git a/test/testdata/mutualtls/valid/client.crt b/test/testdata/mutualtls/valid/client.crt
new file mode 100644
index 0000000000000000000000000000000000000000..7f017fd5ae27f9ce94b0033695f5206639aaa0a6
--- /dev/null
+++ b/test/testdata/mutualtls/valid/client.crt
@@ -0,0 +1,22 @@
+-----BEGIN CERTIFICATE-----
+MIIDkDCCAnigAwIBAgIUel32w9wyRXUoVIzTWaGVZHqMAoIwDQYJKoZIhvcNAQEL
+BQAwUjELMAkGA1UEBhMCQ04xCzAJBgNVBAgMAkdEMQswCQYDVQQHDAJTWjEVMBMG
+A1UECgwMR2l0TGFiLCBJbmMuMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMjQwNTEz
+MDc1NDU1WhcNMjUwNTEzMDc1NDU1WjBSMQswCQYDVQQGEwJDTjELMAkGA1UECAwC
+R0QxCzAJBgNVBAcMAlNaMRUwEwYDVQQKDAxHaXRMYWIsIEluYy4xEjAQBgNVBAMM
+CWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJn1wZjc
+wWLUqXaf+/L919l+wh0pS8BwLlfioUwn3HsshtgOe+Pw/gJY/E0jgBjgQH3yFe4Z
+ViupK13LGbzT1hKx2nnZXnoOss4tw68xo960RSIbEDD/utwEK7sUne4xDbMo1v4q
+ddPVeop3CjCQGwE97E6EA0N3TEvdauc53wE0sh4JoYzKANUZ+qufvbUtH7ICbVq7
+/qvAFaWL9zVXIPmXUE+1X6OpT3IUIN3nQ4fAoEnO1E4FZ06apVtX0DCBP/6L44iO
+0WDch9aJHQci++vTY9aQB7G02ZMCloMTixhBxWusaN6HaXiDcNwxAZvYo5UceyLP
+nEdtDM4f1e179yECAwEAAaNeMFwwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAAB
+MB0GA1UdDgQWBBRDoBadWPhUIkHhSdBhwzs5Y1+NzjAfBgNVHSMEGDAWgBS1WDGw
+4oPLGcum66GDn4c7tQaK3jANBgkqhkiG9w0BAQsFAAOCAQEAVpPoH0f55LXTMvBN
+AD3jM0xGHvEgzaBr4IxX4S5NebOU0FBn1ww2UXlSKDrotI+UOpEWFZAfpgvcKHBz
+LlrOnppOlXQFNQr3cTg0HhHtCnzDmViu5XG3zBqCn0c/Atf4o5jlAhkPAj4M0aWi
+I6oF/Qb69oKEg5/tBd5fU7A+VHzsXVSQYmGMlAEs7WGGfEQ7r6iJd+eXz6Lnwu8r
+rOisxhA32yVtCjwH1pyAFwDQnKzbuNL14UWbUvf2WFj8/4KDFKkdCyElIVmU8yMJ
+zHO3rZHnvKMyk9petIvptmqQH4RF1gmG2uSeJK23M9vVVH5tfCf8n6rYTM9pVglo
+yDZi8A==
+-----END CERTIFICATE-----
diff --git a/test/testdata/mutualtls/valid/client.key b/test/testdata/mutualtls/valid/client.key
new file mode 100644
index 0000000000000000000000000000000000000000..97e855e2627c88488a190880d6a10ae16acc691e
--- /dev/null
+++ b/test/testdata/mutualtls/valid/client.key
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCZ9cGY3MFi1Kl2
+n/vy/dfZfsIdKUvAcC5X4qFMJ9x7LIbYDnvj8P4CWPxNI4AY4EB98hXuGVYrqStd
+yxm809YSsdp52V56DrLOLcOvMaPetEUiGxAw/7rcBCu7FJ3uMQ2zKNb+KnXT1XqK
+dwowkBsBPexOhANDd0xL3WrnOd8BNLIeCaGMygDVGfqrn721LR+yAm1au/6rwBWl
+i/c1VyD5l1BPtV+jqU9yFCDd50OHwKBJztROBWdOmqVbV9AwgT/+i+OIjtFg3IfW
+iR0HIvvr02PWkAextNmTApaDE4sYQcVrrGjeh2l4g3DcMQGb2KOVHHsiz5xHbQzO
+H9Xte/chAgMBAAECggEAAS3Z500M2RrWV798FegTnV7kZzCasSpaxyxdPmCmxmBj
+Vv0YvfiURW8qMtVfr4ZrerDI3LY1IVKjb6Lfe1am/Xp/Dm8CU3kl2EDkTlpDuO//
+VeEdrmENrLjbyeFrn+L1ydEOq8smSbKemeK1Vq+1LpJWFeLacIuVxMCjxzS7rGjD
+G6YiQ7EJJMJ0DiDo7OdUYInNal7/VdYY+yPoTb+W43tfF8jhqqdimTMGKUuuXeWL
+e8G5fNUgmkc7QCatl2GF4wBRUQCjx9UIp080GchQ1dQPjtxLm6/6qdKr3IEYlHne
+OkMCRjOS5+98AwKqq7U7G1LKzhFMkquhlKwS0t7GcQKBgQDVRWyGlpJoSePsUZYX
+BHyJ6NyLsqh7RKF3Yz4HdJJQe9vi8/LXJFU7En+zWKb7JWfMYwt4tESNEGQ3Oj2y
+Ama71QKm+XvRhYz/hfkVglQRQeUDBZMiYvOxzZVyUfX4BxnXFqFdVSgbc7ESfo1T
+guSOtvkfDNsQVJ5R9jzYltDmKQKBgQC4zkoY4hZbZnsiVEZnaYk2Addmj++esKSO
+PcVbrO57s8qwu3Ln8VbtGW28SXGW2VFH3igCcO8Ai2YlrYc6lcEfNBmsGG0XKb1e
+ki0gY/7X433i4BvlnSwAN1Xs49Kusz/Mml+m0q8HivoYETYu3Nxfu0tKTkMbXyFm
+Jg6vgBn4OQKBgQDGRBoWLNjC9x5azaYYk+UrWD3f6SFUJ4NsN+isiaSUCfFrVZqG
+g5JwrkvlcR8bD7Ulf1ZkykGIWpqv9Qbx++WB7Q7gJ8MCD4P68JOVeWmp+XZrjr0w
+FIm03Ah5FNTz1bYiDTnKSKZWjwEozlmYL3FHc7a5NPxafDAKxj3epKZjsQKBgGap
+AfRss6q2dTSOyEVuFPDReQzabGwlCGST3+ybVieVqsUefChoorc3ZwQvcFAyDLr1
+qBgjEEGnLmlDylk7E3r4AELflspFP5MndLYHlmvrTeUYRab59pVwJ+VecYzmukw4
+fWY4p05zX5a7CPRjcHAlpR9z9kdgQzdxcLsBWGvRAoGBAIWZ8JFxf8FKtbqb6Xls
+tvLDb8bJ+ofdbzZYFTKhGjFeFWopE5mZwqFaMvqOEAWfm1mEl0YATqylX0AWeUTg
+2x4/+IYrJdQcSvmki1eesKt9WhqsRkfuoakuHmaXtjgf9kkqTsdUNSHxZLhoFxDY
+3nfEsRGo+Ch0fze+GMJHv0vu
+-----END PRIVATE KEY-----