From 0b61fc5feea95c20fc294d76adaef187456dd98a Mon Sep 17 00:00:00 2001 From: Drew Paettie <3895350+cazlo@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:13:26 -0700 Subject: [PATCH] cmd/go: add GOAUTH support for TLS client certificates GOAUTH can provide HTTP request headers, but it cannot configure client certificates needed during TLS handshakes. Add a built-in mtls method that associates PEM certificate and private key files with one canonical HTTPS origin. Load the files only for matching requests and use a distinct cloned transport for each configuration so redirects re-evaluate certificate selection. Reject mTLS when GOINSECURE applies or when an HTTPS proxy is selected. Keep normal server certificate verification unchanged. Add unit and script tests covering origin matching, redirects, lazy loading, combined and separate PEM files, go-import discovery, and HTTPS GOPROXY use. For #30119 --- src/cmd/go/alldocs.go | 14 + src/cmd/go/internal/auth/auth.go | 115 +++++++- src/cmd/go/internal/auth/auth_test.go | 151 ++++++++++ src/cmd/go/internal/help/helpdoc.go | 12 + src/cmd/go/internal/vcweb/vcstest/vcstest.go | 56 +++- src/cmd/go/internal/web/http.go | 112 ++++++- src/cmd/go/internal/web/http_test.go | 291 +++++++++++++++++++ src/cmd/go/script_test.go | 14 +- src/cmd/go/testdata/script/goauth_mtls.txt | 40 +++ src/cmd/go/testdata/vcstest/auth/mtls.txt | 22 ++ 10 files changed, 814 insertions(+), 13 deletions(-) create mode 100644 src/cmd/go/testdata/script/goauth_mtls.txt create mode 100644 src/cmd/go/testdata/vcstest/auth/mtls.txt diff --git a/src/cmd/go/alldocs.go b/src/cmd/go/alldocs.go index acefe27fb38e66..e70d80f9ac61fa 100644 --- a/src/cmd/go/alldocs.go +++ b/src/cmd/go/alldocs.go @@ -2707,6 +2707,20 @@ // go command will run 'git credential approve/reject' to update // the credential helper's cache. // +// mtls https-origin cert-file [key-file] +// +// Uses the client certificate and private key in the named files to +// authenticate HTTPS requests to https-origin. The origin must not contain +// user information, a non-root path, a query, or a fragment. If its port is +// omitted, port 443 is used. Its hostname must use ASCII or Punycode. The file +// paths must be absolute. If key-file is omitted, cert-file must contain both +// the certificate and private key. The files must contain PEM-encoded data, +// and the private key must not be encrypted. +// The files are read only when a request is made to the matching origin. +// This method configures client authentication only; server certificates are +// verified using the go command's usual trust configuration. +// This method cannot be used with GOINSECURE or an HTTPS proxy. +// // command // // Executes the given command (a space-separated argument list) and attaches diff --git a/src/cmd/go/internal/auth/auth.go b/src/cmd/go/internal/auth/auth.go index 80f54453dc73a0..b30375697284e4 100644 --- a/src/cmd/go/internal/auth/auth.go +++ b/src/cmd/go/internal/auth/auth.go @@ -8,8 +8,11 @@ package auth import ( "cmd/go/internal/base" "cmd/go/internal/cfg" + "cmd/go/internal/web/intercept" + "cmd/internal/quoted" "fmt" "log" + "net" "net/http" "net/url" "os" @@ -20,10 +23,43 @@ import ( ) var ( - credentialCache sync.Map // prefix → http.Header - authOnce sync.Once + credentialCache sync.Map // prefix → http.Header + clientCertificateCache sync.Map // origin → ClientCertificate + authOnce sync.Once ) +// A ClientCertificate describes a certificate and private key to use for +// HTTPS requests to Origin. CertFile may contain both the certificate and key, +// in which case KeyFile is equal to CertFile. +type ClientCertificate struct { + Origin string + CertFile string + KeyFile string +} + +// ClientCertificateForRequest returns the client certificate configured for +// req's HTTPS origin, as derived from req.URL. When test hooks are enabled, +// req.Host takes precedence over req.URL.Host so that test interceptors, +// which rewrite req.URL to point at a local test server, preserve the logical +// request origin. req.Host must never influence the origin otherwise: a +// certificate must not be selected by a Host header that differs from the +// host the connection is made to. +func ClientCertificateForRequest(req *http.Request) (ClientCertificate, bool) { + host := req.URL.Host + if intercept.TestHooksEnabled && req.Host != "" { + host = req.Host + } + origin, err := canonicalHTTPSOrigin(&url.URL{Scheme: req.URL.Scheme, Host: host}) + if err != nil { + return ClientCertificate{}, false + } + cert, ok := clientCertificateCache.Load(origin) + if !ok { + return ClientCertificate{}, false + } + return cert.(ClientCertificate), true +} + // AddCredentials populates the request header with the user's credentials // as specified by the GOAUTH environment variable. // It returns whether any matching credentials were found. @@ -44,12 +80,16 @@ func AddCredentials(client *http.Client, req *http.Request, res *http.Response, // First fetch must have failed; re-invoke GOAUTH commands with url. runGoAuth(client, res, url) } - return loadCredential(req, req.URL.String()) + found := loadCredential(req, req.URL.String()) + if _, ok := ClientCertificateForRequest(req); ok { + found = true + } + return found } // runGoAuth executes authentication commands specified by the GOAUTH -// environment variable handling 'off', 'netrc', and 'git' methods specially, -// and storing retrieved credentials for future access. +// environment variable handling 'off', 'netrc', 'git', and 'mtls' methods +// specially, and storing retrieved credentials for future access. func runGoAuth(client *http.Client, res *http.Response, url string) { var cmdErrs []error // store GOAUTH command errors to log later. goAuthCmds := strings.Split(cfg.GOAUTH, ";") @@ -113,6 +153,16 @@ func runGoAuth(client *http.Client, res *http.Response, url string) { } else { storeCredential(prefix, header) } + case "mtls": + words, err := quoted.Split(command) + if err != nil { + base.Fatalf("go: cannot parse GOAUTH=mtls command %q: %v", command, err) + } + cert, err := parseClientCertificate(words) + if err != nil { + base.Fatalf("go: GOAUTH=mtls: %v", err) + } + clientCertificateCache.Store(cert.Origin, cert) default: credentials, err := runAuthCommand(command, url, res) if err != nil { @@ -129,8 +179,13 @@ func runGoAuth(client *http.Client, res *http.Response, url string) { // If no GOAUTH command provided a credential for the given url // and an error occurred, log the error. if cfg.BuildX && url != "" { - req := &http.Request{Header: make(http.Header)} - if ok := loadCredential(req, url); !ok && len(cmdErrs) > 0 { + req, err := http.NewRequest("GET", url, nil) + hasCredential := err == nil && loadCredential(req, url) + if err == nil { + _, hasClientCertificate := ClientCertificateForRequest(req) + hasCredential = hasCredential || hasClientCertificate + } + if !hasCredential && len(cmdErrs) > 0 { log.Printf("GOAUTH encountered errors for %s:", url) for _, err := range cmdErrs { log.Printf(" %v", err) @@ -139,6 +194,52 @@ func runGoAuth(client *http.Client, res *http.Response, url string) { } } +func parseClientCertificate(words []string) (ClientCertificate, error) { + if len(words) != 3 && len(words) != 4 { + return ClientCertificate{}, fmt.Errorf("usage: mtls https-origin cert-file [key-file]") + } + u, err := url.ParseRequestURI(words[1]) + if err != nil { + return ClientCertificate{}, fmt.Errorf("invalid HTTPS origin %q: %v", words[1], err) + } + origin, err := canonicalHTTPSOrigin(u) + if err != nil { + return ClientCertificate{}, err + } + if !filepath.IsAbs(words[2]) { + return ClientCertificate{}, fmt.Errorf("certificate file must be an absolute path") + } + keyFile := words[2] + if len(words) == 4 { + keyFile = words[3] + if !filepath.IsAbs(keyFile) { + return ClientCertificate{}, fmt.Errorf("key file must be an absolute path") + } + } + return ClientCertificate{Origin: origin, CertFile: words[2], KeyFile: keyFile}, nil +} + +func canonicalHTTPSOrigin(u *url.URL) (string, error) { + if u.Scheme != "https" || u.Host == "" || u.User != nil || (u.Path != "" && u.Path != "/") || u.ForceQuery || u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("origin must be an HTTPS URL without user information, a non-root path, query, or fragment") + } + host := strings.TrimSuffix(u.Hostname(), ".") + if host == "" { + return "", fmt.Errorf("HTTPS origin is missing a hostname") + } + for i := 0; i < len(host); i++ { + if host[i] >= 0x80 { + return "", fmt.Errorf("HTTPS origin hostname must use ASCII or Punycode") + } + } + host = strings.ToLower(host) + port := u.Port() + if port == "" { + port = "443" + } + return "https://" + net.JoinHostPort(host, port), nil +} + // loadCredential retrieves cached credentials for the given url and adds // them to the request headers. func loadCredential(req *http.Request, rawURL string) bool { diff --git a/src/cmd/go/internal/auth/auth_test.go b/src/cmd/go/internal/auth/auth_test.go index 64565b59d8d33b..169a6aaf97a3dd 100644 --- a/src/cmd/go/internal/auth/auth_test.go +++ b/src/cmd/go/internal/auth/auth_test.go @@ -5,6 +5,9 @@ package auth import ( + "cmd/go/internal/cfg" + "cmd/go/internal/web/intercept" + "cmd/internal/quoted" "net/http" "reflect" "testing" @@ -103,3 +106,151 @@ func TestCredentialCacheURLQuery(t *testing.T) { t.Fatalf("got %v, want %v", got.Header, want.Header) } } + +func TestParseClientCertificate(t *testing.T) { + dir := t.TempDir() + certFile := dir + "/client cert.pem" + keyFile := dir + "/client key.pem" + + for _, test := range []struct { + name string + words []string + want ClientCertificate + }{ + { + name: "combined PEM", + words: []string{"mtls", "https://REGISTRY.example.com./", certFile}, + want: ClientCertificate{Origin: "https://registry.example.com:443", CertFile: certFile, KeyFile: certFile}, + }, + { + name: "separate key and port", + words: []string{"mtls", "https://registry.example.com:8443", certFile, keyFile}, + want: ClientCertificate{Origin: "https://registry.example.com:8443", CertFile: certFile, KeyFile: keyFile}, + }, + } { + t.Run(test.name, func(t *testing.T) { + got, err := parseClientCertificate(test.words) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, test.want) { + t.Fatalf("parseClientCertificate() = %#v, want %#v", got, test.want) + } + }) + } + + for _, words := range [][]string{ + {"mtls"}, + {"mtls", "http://registry.example.com", certFile}, + {"mtls", "https://user@registry.example.com", certFile}, + {"mtls", "https://registry.example.com/path", certFile}, + {"mtls", "https://registry.example.com?query", certFile}, + {"mtls", "https://registry.example.com?", certFile}, + {"mtls", "https://\u0130.example.com", certFile}, + {"mtls", "https://registry.example.com", "relative-cert.pem"}, + {"mtls", "https://registry.example.com", certFile, "relative-key.pem"}, + } { + if _, err := parseClientCertificate(words); err == nil { + t.Errorf("parseClientCertificate(%q) succeeded, want error", words) + } + } +} + +func TestClientCertificateForRequest(t *testing.T) { + cert := ClientCertificate{ + Origin: "https://registry.example.com:443", + CertFile: "/client-cert.pem", + KeyFile: "/client-key.pem", + } + clientCertificateCache.Store(cert.Origin, cert) + defer clientCertificateCache.Delete(cert.Origin) + lookalikeCert := ClientCertificate{ + Origin: "https://i.example:443", + CertFile: "/lookalike-client-cert.pem", + KeyFile: "/lookalike-client-key.pem", + } + clientCertificateCache.Store(lookalikeCert.Origin, lookalikeCert) + defer clientCertificateCache.Delete(lookalikeCert.Origin) + + for _, test := range []struct { + name string + url string + host string + testHooks bool + want bool + }{ + {name: "exact origin", url: "https://registry.example.com/module", want: true}, + {name: "case and trailing dot", url: "https://REGISTRY.example.com./module", want: true}, + {name: "explicit default port", url: "https://registry.example.com:443/module", want: true}, + {name: "different port", url: "https://registry.example.com:8443/module", want: false}, + {name: "subdomain", url: "https://sub.registry.example.com/module", want: false}, + {name: "different scheme", url: "http://registry.example.com/module", want: false}, + {name: "IDNA lookalike", url: "https://\u0130.example/module", want: false}, + {name: "intercepted logical Host", url: "https://127.0.0.1/module", host: "registry.example.com", testHooks: true, want: true}, + {name: "Host ignored without test hooks", url: "https://127.0.0.1/module", host: "registry.example.com", want: false}, + } { + t.Run(test.name, func(t *testing.T) { + if test.testHooks != intercept.TestHooksEnabled { + defer func(saved bool) { intercept.TestHooksEnabled = saved }(intercept.TestHooksEnabled) + intercept.TestHooksEnabled = test.testHooks + } + req, err := http.NewRequest("GET", test.url, nil) + if err != nil { + t.Fatal(err) + } + req.Host = test.host + got, ok := ClientCertificateForRequest(req) + if ok != test.want { + t.Fatalf("ClientCertificateForRequest() found = %t, want %t", ok, test.want) + } + if ok && !reflect.DeepEqual(got, cert) { + t.Fatalf("ClientCertificateForRequest() = %#v, want %#v", got, cert) + } + }) + } +} + +func TestRunGoAuthMTLS(t *testing.T) { + oldGOAUTH := cfg.GOAUTH + defer func() { cfg.GOAUTH = oldGOAUTH }() + + dir := t.TempDir() + first := dir + "/first client.pem" + second := dir + "/second client.pem" + origin := "https://registry.example.com:443" + defer clientCertificateCache.Delete(origin) + + // The first GOAUTH method has priority when multiple methods configure + // the same origin. + firstCommand, err := quoted.Join([]string{"mtls", "https://registry.example.com", first}) + if err != nil { + t.Fatal(err) + } + secondCommand, err := quoted.Join([]string{"mtls", "https://registry.example.com", second}) + if err != nil { + t.Fatal(err) + } + cfg.GOAUTH = firstCommand + "; " + secondCommand + runGoAuth(http.DefaultClient, nil, "") + + req, err := http.NewRequest("GET", "https://registry.example.com/module", nil) + if err != nil { + t.Fatal(err) + } + got, ok := ClientCertificateForRequest(req) + if !ok { + t.Fatal("ClientCertificateForRequest did not find GOAUTH=mtls configuration") + } + want := ClientCertificate{Origin: origin, CertFile: first, KeyFile: first} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ClientCertificateForRequest() = %#v, want %#v", got, want) + } + + req, err = http.NewRequest("GET", "https://registry.example.com/module", nil) + if err != nil { + t.Fatal(err) + } + if !AddCredentials(http.DefaultClient, req, nil, "") { + t.Error("AddCredentials did not report the matching client certificate") + } +} diff --git a/src/cmd/go/internal/help/helpdoc.go b/src/cmd/go/internal/help/helpdoc.go index b305457a23d104..2646558efc0e0f 100644 --- a/src/cmd/go/internal/help/helpdoc.go +++ b/src/cmd/go/internal/help/helpdoc.go @@ -1093,6 +1093,18 @@ git dir Runs 'git credential fill' in dir and uses its credentials. The go command will run 'git credential approve/reject' to update the credential helper's cache. +mtls https-origin cert-file [key-file] + Uses the client certificate and private key in the named files to + authenticate HTTPS requests to https-origin. The origin must not contain + user information, a non-root path, a query, or a fragment. If its port is + omitted, port 443 is used. Its hostname must use ASCII or Punycode. The file + paths must be absolute. If key-file is omitted, cert-file must contain both + the certificate and private key. The files must contain PEM-encoded data, + and the private key must not be encrypted. + The files are read only when a request is made to the matching origin. + This method configures client authentication only; server certificates are + verified using the go command's usual trust configuration. + This method cannot be used with GOINSECURE or an HTTPS proxy. command Executes the given command (a space-separated argument list) and attaches the provided headers to HTTPS requests. diff --git a/src/cmd/go/internal/vcweb/vcstest/vcstest.go b/src/cmd/go/internal/vcweb/vcstest/vcstest.go index 224cfd791931ca..41fd92be48eb36 100644 --- a/src/cmd/go/internal/vcweb/vcstest/vcstest.go +++ b/src/cmd/go/internal/vcweb/vcstest/vcstest.go @@ -11,6 +11,9 @@ import ( "cmd/go/internal/vcs" "cmd/go/internal/vcweb" "cmd/go/internal/web/intercept" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/tls" "crypto/x509" "encoding/pem" @@ -18,12 +21,15 @@ import ( "internal/testenv" "io" "log" + "math/big" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" + "strings" "testing" + "time" ) var Hosts = []string{ @@ -84,8 +90,9 @@ func NewServer() (srv *Server, err error) { } }() - srvHTTPS := httptest.NewUnstartedServer(handler) + srvHTTPS := httptest.NewUnstartedServer(requireClientCertificate(handler)) srvHTTPS.Config.ErrorLog = testLogger() + srvHTTPS.TLS = &tls.Config{ClientAuth: tls.RequestClientCert} srvHTTPS.StartTLS() httpsURL, err := url.Parse(srvHTTPS.URL) if err != nil { @@ -120,6 +127,16 @@ func NewServer() (srv *Server, err error) { return srv, nil } +func requireClientCertificate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if strings.HasPrefix(req.URL.Path, "/auth/mtls") && (req.TLS == nil || len(req.TLS.PeerCertificates) == 0) { + http.Error(w, "client certificate required", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, req) + }) +} + func testLogger() *log.Logger { return log.New(httpLogger{}, "vcweb: ", 0) } @@ -163,6 +180,43 @@ func (srv *Server) WriteCertificateFile() (string, error) { return filename, nil } +// WriteClientCertificateFiles writes a client certificate and private key for +// authenticating to the test server. +func (srv *Server) WriteClientCertificateFiles() (certFile, keyFile string, err error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return "", "", err + } + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return "", "", err + } + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return "", "", err + } + + certFile = filepath.Join(srv.workDir, "client-cert.pem") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + if err := os.WriteFile(certFile, certPEM, 0644); err != nil { + return "", "", err + } + keyFile = filepath.Join(srv.workDir, "client-key.pem") + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(keyFile, keyPEM, 0600); err != nil { + return "", "", err + } + return certFile, keyFile, nil +} + // TLSClient returns an http.Client that can talk to the httptest.Server // whose certificate is written to the given file path. func TLSClient(certFile string) (*http.Client, error) { diff --git a/src/cmd/go/internal/web/http.go b/src/cmd/go/internal/web/http.go index 12109412ca7d43..9ee588c55f9199 100644 --- a/src/cmd/go/internal/web/http.go +++ b/src/cmd/go/internal/web/http.go @@ -22,6 +22,7 @@ import ( urlpkg "net/url" "os" "strings" + "sync" "time" "cmd/go/internal/auth" @@ -36,7 +37,7 @@ const userAgent = "GoCommand/1 (+https://go.dev/cmd/go)" // impatientInsecureHTTPClient is used with GOINSECURE, // when we're connecting to https servers that might not be there // or might be using self-signed certificates. -var impatientInsecureHTTPClient = &http.Client{ +var impatientInsecureHTTPClient = mtlsHTTPClient(&http.Client{ CheckRedirect: checkRedirect, Timeout: 5 * time.Second, Transport: &http.Transport{ @@ -45,9 +46,114 @@ var impatientInsecureHTTPClient = &http.Client{ InsecureSkipVerify: true, }, }, +}, true) + +var securityPreservingDefaultClient = securityPreservingHTTPClient(mtlsHTTPClient(http.DefaultClient, false)) + +type clientCertificateLookup func(*http.Request) (auth.ClientCertificate, bool) + +// mtlsHTTPClient returns a client that uses origin-scoped client certificates +// configured by GOAUTH. Certificate files are not read until the first request +// to a matching origin. +func mtlsHTTPClient(original *http.Client, insecure bool) *http.Client { + return mtlsHTTPClientWithLookup(original, insecure, auth.ClientCertificateForRequest) +} + +func mtlsHTTPClientWithLookup(original *http.Client, insecure bool, lookup clientCertificateLookup) *http.Client { + c := new(http.Client) + *c = *original + base := original.Transport + if base == nil { + base = http.DefaultTransport + } + c.Transport = &mtlsTransport{base: base, insecure: insecure, lookup: lookup} + return c } -var securityPreservingDefaultClient = securityPreservingHTTPClient(http.DefaultClient) +type mtlsTransport struct { + base http.RoundTripper + insecure bool + lookup clientCertificateLookup + states sync.Map // auth.ClientCertificate → *mtlsTransportState +} + +type mtlsTransportState struct { + mu sync.Mutex + once sync.Once + transport http.RoundTripper + err error +} + +func (t *mtlsTransport) RoundTrip(req *http.Request) (*http.Response, error) { + cert, ok := t.lookup(req) + if !ok { + return t.base.RoundTrip(req) + } + if t.insecure { + return nil, fmt.Errorf("GOAUTH=mtls cannot be used with GOINSECURE for %s", cert.Origin) + } + + value, _ := t.states.LoadOrStore(cert, new(mtlsTransportState)) + state := value.(*mtlsTransportState) + state.once.Do(func() { + state.init(t.base, cert) + }) + if state.err != nil { + return nil, state.err + } + return state.transport.RoundTrip(req) +} + +func (s *mtlsTransportState) init(roundTripper http.RoundTripper, certConfig auth.ClientCertificate) { + s.mu.Lock() + defer s.mu.Unlock() + + base, ok := roundTripper.(*http.Transport) + if !ok { + s.err = fmt.Errorf("GOAUTH=mtls requires an *http.Transport, but the HTTP client uses %T", roundTripper) + return + } + + cert, err := tls.LoadX509KeyPair(certConfig.CertFile, certConfig.KeyFile) + if err != nil { + s.err = fmt.Errorf("loading GOAUTH=mtls client certificate for %s: %w", certConfig.Origin, err) + return + } + + transport := base.Clone() + if proxy := transport.Proxy; proxy != nil { + transport.Proxy = func(req *http.Request) (*urlpkg.URL, error) { + u, err := proxy(req) + if err == nil && u != nil && strings.EqualFold(u.Scheme, "https") { + return nil, fmt.Errorf("GOAUTH=mtls does not support HTTPS proxy %s", u.Redacted()) + } + return u, err + } + } + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = new(tls.Config) + } else { + transport.TLSClientConfig = transport.TLSClientConfig.Clone() + } + transport.TLSClientConfig.Certificates = []tls.Certificate{cert} + transport.TLSClientConfig.GetClientCertificate = nil + s.transport = transport +} + +func (t *mtlsTransport) CloseIdleConnections() { + if closer, ok := t.base.(interface{ CloseIdleConnections() }); ok { + closer.CloseIdleConnections() + } + t.states.Range(func(_, value any) bool { + state := value.(*mtlsTransportState) + state.mu.Lock() + defer state.mu.Unlock() + if closer, ok := state.transport.(interface{ CloseIdleConnections() }); ok { + closer.CloseIdleConnections() + } + return true + }) +} // securityPreservingHTTPClient returns a client that is like the original // but rejects redirects to plain-HTTP URLs if the original URL was secure. @@ -128,7 +234,7 @@ func get(security SecurityMode, url *urlpkg.URL) (*Response, error) { if security == Insecure && url.Scheme == "https" { client = impatientInsecureHTTPClient } else if intercepted && t.Client != nil { - client = securityPreservingHTTPClient(t.Client) + client = securityPreservingHTTPClient(mtlsHTTPClient(t.Client, false)) } else { client = securityPreservingDefaultClient } diff --git a/src/cmd/go/internal/web/http_test.go b/src/cmd/go/internal/web/http_test.go index 84a41d6dcb2c9a..b7204c23ae26a7 100644 --- a/src/cmd/go/internal/web/http_test.go +++ b/src/cmd/go/internal/web/http_test.go @@ -6,11 +6,25 @@ package web import ( "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/pem" "io" + "math/big" + "net" "net/http" "net/http/httptest" "net/url" + "os" + "strings" "testing" + "time" + + "cmd/go/internal/auth" ) func TestUserAgent(t *testing.T) { @@ -76,3 +90,280 @@ func TestGoGet1(t *testing.T) { t.Errorf("http status != 200: %v", res.Status) } } + +func TestMTLSClient(t *testing.T) { + for _, test := range []struct { + name string + combined bool + }{ + {name: "separate certificate and key files", combined: false}, + {name: "combined certificate and key file", combined: true}, + } { + t.Run(test.name, func(t *testing.T) { + receivedCertificate := make(chan bool, 1) + ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedCertificate <- len(r.TLS.PeerCertificates) > 0 + })) + clientCert, clientCAs := newClientCertificate(t) + ts.TLS = &tls.Config{ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: clientCAs} + ts.StartTLS() + defer ts.Close() + + var certFile, keyFile string + if test.combined { + certFile = writeCombinedCertificateFile(t, clientCert) + keyFile = certFile + } else { + certFile, keyFile = writeCertificateFiles(t, clientCert) + } + u, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + cert := auth.ClientCertificate{Origin: u.Scheme + "://" + u.Host, CertFile: certFile, KeyFile: keyFile} + client := mtlsHTTPClientWithLookup(ts.Client(), false, lookupClientCertificate(cert)) + res, err := client.Get(ts.URL) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if !<-receivedCertificate { + t.Error("server did not receive a client certificate") + } + }) + } +} + +func TestMTLSClientLazyForOtherHost(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer ts.Close() + + cert := auth.ClientCertificate{ + Origin: "https://mtls.example.com:443", + CertFile: "missing-client-cert.pem", + KeyFile: "missing-client-key.pem", + } + client := mtlsHTTPClientWithLookup(ts.Client(), false, lookupClientCertificate(cert)) + res, err := client.Get(ts.URL) + if err != nil { + t.Fatalf("request to an unrelated host unexpectedly loaded the certificate: %v", err) + } + res.Body.Close() +} + +func TestMTLSClientMissingCertificate(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer ts.Close() + + u, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + cert := auth.ClientCertificate{ + Origin: u.Scheme + "://" + u.Host, + CertFile: "missing-client-cert.pem", + KeyFile: "missing-client-key.pem", + } + client := mtlsHTTPClientWithLookup(ts.Client(), false, lookupClientCertificate(cert)) + _, err = client.Get(ts.URL) + if err == nil || !strings.Contains(err.Error(), "loading GOAUTH=mtls client certificate") { + t.Fatalf("client.Get error = %v, want missing GOAUTH=mtls certificate error", err) + } +} + +func TestMTLSClientRedirectDoesNotForwardCertificate(t *testing.T) { + destinationReceivedCertificate := make(chan bool, 1) + destination := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + destinationReceivedCertificate <- len(r.TLS.PeerCertificates) > 0 + })) + destination.TLS = &tls.Config{ClientAuth: tls.RequestClientCert} + destination.StartTLS() + defer destination.Close() + + destinationURL, err := url.Parse(destination.URL) + if err != nil { + t.Fatal(err) + } + destinationURL.Host = "other.example.com:" + destinationURL.Port() + + source := httptest.NewUnstartedServer(http.RedirectHandler(destinationURL.String(), http.StatusFound)) + source.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert} + source.StartTLS() + defer source.Close() + + certFile, keyFile := writeCertificateFiles(t, source.TLS.Certificates[0]) + sourceURL, err := url.Parse(source.URL) + if err != nil { + t.Fatal(err) + } + sourceURL.Host = "mtls.example.com:" + sourceURL.Port() + baseClient := source.Client() + transport := baseClient.Transport.(*http.Transport).Clone() + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + _, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort("127.0.0.1", port)) + } + baseClient.Transport = transport + cert := auth.ClientCertificate{Origin: sourceURL.Scheme + "://" + sourceURL.Host, CertFile: certFile, KeyFile: keyFile} + client := mtlsHTTPClientWithLookup(baseClient, false, lookupClientCertificate(cert)) + res, err := client.Get(sourceURL.String()) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if <-destinationReceivedCertificate { + t.Error("redirect destination received the client certificate") + } +} + +func TestMTLSClientRejectsHTTPSProxy(t *testing.T) { + ts := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer ts.Close() + + certFile, keyFile := writeCertificateFiles(t, ts.TLS.Certificates[0]) + u, err := url.Parse(ts.URL) + if err != nil { + t.Fatal(err) + } + baseClient := ts.Client() + transport := baseClient.Transport.(*http.Transport).Clone() + transport.Proxy = func(*http.Request) (*url.URL, error) { + return url.Parse("https://proxy.example.com") + } + baseClient.Transport = transport + cert := auth.ClientCertificate{Origin: u.Scheme + "://" + u.Host, CertFile: certFile, KeyFile: keyFile} + client := mtlsHTTPClientWithLookup(baseClient, false, lookupClientCertificate(cert)) + _, err = client.Get(ts.URL) + if err == nil || !strings.Contains(err.Error(), "GOAUTH=mtls does not support HTTPS proxy") { + t.Fatalf("client.Get error = %v, want HTTPS proxy error", err) + } +} + +func TestMTLSClientRejectsGOINSECURE(t *testing.T) { + cert := auth.ClientCertificate{ + Origin: "https://mtls.example.com:443", + CertFile: "missing-client-cert.pem", + KeyFile: "missing-client-key.pem", + } + client := mtlsHTTPClientWithLookup(http.DefaultClient, true, lookupClientCertificate(cert)) + _, err := client.Get("https://mtls.example.com") + if err == nil || !strings.Contains(err.Error(), "GOAUTH=mtls cannot be used with GOINSECURE") { + t.Fatalf("client.Get error = %v, want GOINSECURE error", err) + } +} + +func lookupClientCertificate(cert auth.ClientCertificate) clientCertificateLookup { + return func(req *http.Request) (auth.ClientCertificate, bool) { + host := req.URL.Host + if req.Host != "" { + host = req.Host + } + u := &url.URL{Scheme: req.URL.Scheme, Host: host} + port := u.Port() + if port == "" { + port = "443" + } + origin := u.Scheme + "://" + net.JoinHostPort(strings.ToLower(strings.TrimSuffix(u.Hostname(), ".")), port) + return cert, origin == cert.Origin + } +} + +func newClientCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) { + t.Helper() + + now := time.Now() + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + IsCA: true, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + ca, err := x509.ParseCertificate(caDER) + if err != nil { + t.Fatal(err) + } + + clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + clientTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + clientDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, ca, &clientKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + + pool := x509.NewCertPool() + pool.AddCert(ca) + return tls.Certificate{Certificate: [][]byte{clientDER, caDER}, PrivateKey: clientKey}, pool +} + +func writeCertificateFiles(t *testing.T, cert tls.Certificate) (certFile, keyFile string) { + t.Helper() + + var certPEM bytes.Buffer + for _, der := range cert.Certificate { + if err := pem.Encode(&certPEM, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil { + t.Fatal(err) + } + } + key, err := x509.MarshalPKCS8PrivateKey(cert.PrivateKey) + if err != nil { + t.Fatal(err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}) + + dir := t.TempDir() + certFile = dir + "/client-cert.pem" + keyFile = dir + "/client-key.pem" + if err := os.WriteFile(certFile, certPEM.Bytes(), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyFile, keyPEM, 0600); err != nil { + t.Fatal(err) + } + return certFile, keyFile +} + +func writeCombinedCertificateFile(t *testing.T, cert tls.Certificate) string { + t.Helper() + + var combined bytes.Buffer + for _, der := range cert.Certificate { + if err := pem.Encode(&combined, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil { + t.Fatal(err) + } + } + key, err := x509.MarshalPKCS8PrivateKey(cert.PrivateKey) + if err != nil { + t.Fatal(err) + } + if err := pem.Encode(&combined, &pem.Block{Type: "PRIVATE KEY", Bytes: key}); err != nil { + t.Fatal(err) + } + + file := t.TempDir() + "/client.pem" + if err := os.WriteFile(file, combined.Bytes(), 0600); err != nil { + t.Fatal(err) + } + return file +} diff --git a/src/cmd/go/script_test.go b/src/cmd/go/script_test.go index e9b386ec660cd0..071fcd8488dce5 100644 --- a/src/cmd/go/script_test.go +++ b/src/cmd/go/script_test.go @@ -28,6 +28,7 @@ import ( "cmd/go/internal/cfg" "cmd/go/internal/gover" "cmd/go/internal/vcweb/vcstest" + "cmd/internal/quoted" "cmd/internal/script" "cmd/internal/script/scripttest" @@ -60,6 +61,10 @@ func TestScript(t *testing.T) { if err != nil { t.Fatal(err) } + clientCertFile, clientKeyFile, err := srv.WriteClientCertificateFiles() + if err != nil { + t.Fatal(err) + } StartProxy() @@ -90,7 +95,7 @@ func TestScript(t *testing.T) { t.Cleanup(cancel) } - env, err := scriptEnv(srv, certFile) + env, err := scriptEnv(srv, certFile, clientCertFile, clientKeyFile) if err != nil { t.Fatal(err) } @@ -213,7 +218,7 @@ func initScriptDirs(t testing.TB, s *script.State) (telemetryDir string) { return telemetryDir } -func scriptEnv(srv *vcstest.Server, srvCertFile string) ([]string, error) { +func scriptEnv(srv *vcstest.Server, srvCertFile, clientCertFile, clientKeyFile string) ([]string, error) { httpURL, err := url.Parse(srv.HTTP.URL) if err != nil { return nil, err @@ -222,6 +227,10 @@ func scriptEnv(srv *vcstest.Server, srvCertFile string) ([]string, error) { if err != nil { return nil, err } + mtlsGOAUTH, err := quoted.Join([]string{"mtls", "https://vcs-test.golang.org", clientCertFile, clientKeyFile}) + if err != nil { + return nil, err + } env := []string{ pathEnvName() + "=" + testBin + string(filepath.ListSeparator) + os.Getenv(pathEnvName()), homeEnvName() + "=/no-home", @@ -245,6 +254,7 @@ func scriptEnv(srv *vcstest.Server, srvCertFile string) ([]string, error) { "TESTGO_VCSTEST_HOST=" + httpURL.Host, "TESTGO_VCSTEST_TLS_HOST=" + httpsURL.Host, "TESTGO_VCSTEST_CERT=" + srvCertFile, + "TESTGO_VCSTEST_MTLS_GOAUTH=" + mtlsGOAUTH, "TESTGONETWORK=panic", // cleared by the [net] condition "GOSUMDB=" + testSumDBVerifierKey, "TESTGO_SUMDB=" + testSumDBName, diff --git a/src/cmd/go/testdata/script/goauth_mtls.txt b/src/cmd/go/testdata/script/goauth_mtls.txt new file mode 100644 index 00000000000000..006ad10447b22c --- /dev/null +++ b/src/cmd/go/testdata/script/goauth_mtls.txt @@ -0,0 +1,40 @@ +# GOAUTH=mtls certificate files are loaded only when the configured origin is +# contacted. A request to another HTTPS origin must not load missing files. +env GOAUTH='mtls https://mtls.example.com '$WORK'/missing-client-cert.pem '$WORK'/missing-client-key.pem; netrc' +env GOPROXY=direct +env GOSUMDB=off +env NETRC=$WORK/netrc +cp go.mod.orig go.mod +go get vcs-test.golang.org/auth/or401 + +# A server that requires a client certificate rejects an unauthenticated fetch. +env GOAUTH=off +cp go.mod.orig go.mod +! go get vcs-test.golang.org/auth/mtls +stderr 'client certificate required' + +# The same fetch succeeds with the origin-scoped client certificate. +env GOAUTH=$TESTGO_VCSTEST_MTLS_GOAUTH +cp go.mod.orig go.mod +go get vcs-test.golang.org/auth/mtls +go list -m all +stdout vcs-test.golang.org/auth/mtls + +# GOAUTH=mtls also authenticates requests made directly to an HTTPS GOPROXY. +go clean -modcache +env GOPROXY=https://vcs-test.golang.org/auth/mtls +env GOAUTH=off +! go mod download vcs-test.golang.org/auth/mtls@v0.0.0-20190405155051-52df474c8a8b +stderr 'client certificate required' + +env GOAUTH=$TESTGO_VCSTEST_MTLS_GOAUTH +go mod download vcs-test.golang.org/auth/mtls@v0.0.0-20190405155051-52df474c8a8b + +-- go.mod.orig -- +module private.example.com + +go 1.24 +-- $WORK/netrc -- +machine vcs-test.golang.org + login aladdin + password opensesame diff --git a/src/cmd/go/testdata/vcstest/auth/mtls.txt b/src/cmd/go/testdata/vcstest/auth/mtls.txt new file mode 100644 index 00000000000000..3610d8c814e879 --- /dev/null +++ b/src/cmd/go/testdata/vcstest/auth/mtls.txt @@ -0,0 +1,22 @@ +handle dir + +modzip vcs-test.golang.org/auth/mtls/@v/v0.0.0-20190405155051-52df474c8a8b.zip vcs-test.golang.org/auth/mtls@v0.0.0-20190405155051-52df474c8a8b .moddir + +-- index.html -- + + + +-- vcs-test.golang.org/auth/mtls/@v/list -- +v0.0.0-20190405155051-52df474c8a8b +-- vcs-test.golang.org/auth/mtls/@v/v0.0.0-20190405155051-52df474c8a8b.info -- +{"Version":"v0.0.0-20190405155051-52df474c8a8b","Time":"2019-04-05T15:50:51Z"} +-- vcs-test.golang.org/auth/mtls/@v/v0.0.0-20190405155051-52df474c8a8b.mod -- +module vcs-test.golang.org/auth/mtls + +go 1.13 +-- .moddir/go.mod -- +module vcs-test.golang.org/auth/mtls + +go 1.13 +-- .moddir/mtls.go -- +package mtls