diff --git a/.changes/v1.15/ENHANCEMENTS-20251221-143044.yaml b/.changes/v1.15/ENHANCEMENTS-20251221-143044.yaml new file mode 100644 index 000000000000..7b68635fc90e --- /dev/null +++ b/.changes/v1.15/ENHANCEMENTS-20251221-143044.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: 'HTTP backend: add ca_file option for custom CA trust' +time: 2025-12-21T14:30:44.196824246-08:00 +custom: + Issue: "36937" diff --git a/internal/backend/remote-state/http/backend.go b/internal/backend/remote-state/http/backend.go index dda03c06b409..8ce594b945f8 100644 --- a/internal/backend/remote-state/http/backend.go +++ b/internal/backend/remote-state/http/backend.go @@ -12,6 +12,7 @@ import ( "net/http" "net/url" "time" + "os" "github.com/hashicorp/go-retryablehttp" "github.com/zclconf/go-cty/cty" @@ -90,6 +91,11 @@ func New() backend.Backend { Optional: true, Description: "The maximum time in seconds to wait between HTTP request attempts", }, + "ca_file": { + Type: cty.String, + Optional: true, + Description: "Path to a PEM-encoded CA certificate bundle to use when verifying server TLS certificates. May be set via TF_HTTP_CA_FILE.", + }, "client_ca_certificate_pem": { Type: cty.String, Optional: true, @@ -117,6 +123,21 @@ type Backend struct { client *httpClient } +func loadCACertsFromFile(path string) (*x509.CertPool, error) { + pemData, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read ca_file %q: %w", path, err) + } + + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pemData) { + return nil, fmt.Errorf("failed to parse CA certificates in %q", path) + } + + return pool, nil +} + + func (b *Backend) Configure(configVal cty.Value) tfdiags.Diagnostics { address := backendbase.GetAttrEnvDefaultFallback( configVal, "address", @@ -257,6 +278,10 @@ func (b *Backend) configureTLS(client *retryablehttp.Client, configVal cty.Value skipCertVerification := backendbase.MustBoolValue( backendbase.GetAttrDefault(configVal, "skip_cert_verification", cty.False), ) + caFile := backendbase.GetAttrEnvDefaultFallback( + configVal, "ca_file", + "TF_HTTP_CA_FILE", cty.StringVal(""), + ).AsString() clientCACertificatePem := backendbase.GetAttrEnvDefaultFallback( configVal, "client_ca_certificate_pem", "TF_HTTP_CLIENT_CA_CERTIFICATE_PEM", cty.StringVal(""), @@ -269,7 +294,12 @@ func (b *Backend) configureTLS(client *retryablehttp.Client, configVal cty.Value configVal, "client_private_key_pem", "TF_HTTP_CLIENT_PRIVATE_KEY_PEM", cty.StringVal(""), ).AsString() - if !skipCertVerification && clientCACertificatePem == "" && clientCertificatePem == "" && clientPrivateKeyPem == "" { + // No TLS customization required + if !skipCertVerification && + caFile == "" && + clientCACertificatePem == "" && + clientCertificatePem == "" && + clientPrivateKeyPem == "" { return nil } if clientCertificatePem != "" && clientPrivateKeyPem == "" { @@ -278,25 +308,43 @@ func (b *Backend) configureTLS(client *retryablehttp.Client, configVal cty.Value if clientPrivateKeyPem != "" && clientCertificatePem == "" { return fmt.Errorf("client_private_key_pem is set but client_certificate_pem is not") } + transport, ok := client.HTTPClient.Transport.(*http.Transport) + if !ok { + return fmt.Errorf("unexpected HTTP transport type") + } // TLS configuration is needed; create an object and configure it - var tlsConfig tls.Config - client.HTTPClient.Transport.(*http.Transport).TLSClientConfig = &tlsConfig + tlsConfig := &tls.Config{} + transport.TLSClientConfig = tlsConfig if skipCertVerification { // ignores TLS verification tlsConfig.InsecureSkipVerify = true } - if clientCACertificatePem != "" { - // trust servers based on a CA - tlsConfig.RootCAs = x509.NewCertPool() - if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(clientCACertificatePem)) { - return errors.New("failed to append certs") + // trust servers based on a CA + if caFile != "" || clientCACertificatePem != "" { + rootCAs := x509.NewCertPool() + + if caFile != "" { + filePool, err := loadCACertsFromFile(caFile) + if err != nil { + return err + } + rootCAs = filePool } + if clientCACertificatePem != "" { + if !rootCAs.AppendCertsFromPEM([]byte(clientCACertificatePem)) { + return errors.New("failed to append certs") + } + } + tlsConfig.RootCAs = rootCAs } if clientCertificatePem != "" && clientPrivateKeyPem != "" { // attach a client certificate to the TLS handshake (aka mTLS) - certificate, err := tls.X509KeyPair([]byte(clientCertificatePem), []byte(clientPrivateKeyPem)) + certificate, err := tls.X509KeyPair( + []byte(clientCertificatePem), + []byte(clientPrivateKeyPem), + ) if err != nil { return fmt.Errorf("cannot load client certificate: %w", err) } diff --git a/internal/backend/remote-state/http/backend_test.go b/internal/backend/remote-state/http/backend_test.go index dcf7f4d42014..761a4493d5fa 100644 --- a/internal/backend/remote-state/http/backend_test.go +++ b/internal/backend/remote-state/http/backend_test.go @@ -4,14 +4,22 @@ package http import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http" "os" "testing" "time" + "github.com/hashicorp/go-retryablehttp" + "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/configs" "github.com/zclconf/go-cty/cty" - - "github.com/hashicorp/terraform/internal/backend" ) func TestBackend_impl(t *testing.T) { @@ -165,3 +173,175 @@ func testWithEnv(t *testing.T, key string, value string) func() { } } } +func mustMakeCA(t *testing.T, cn string) (certPEM []byte) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + + serial, err := rand.Int(rand.Reader, big.NewInt(1<<62)) + if err != nil { + t.Fatalf("serial: %v", err) + } + + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{ + CommonName: cn, + }, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatalf("CreateCertificate: %v", err) + } + + var buf bytes.Buffer + if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil { + t.Fatalf("pem.Encode: %v", err) + } + + return buf.Bytes() +} + +func mustSubjects(t *testing.T, pool *x509.CertPool) [][]byte { + t.Helper() + + if pool == nil { + t.Fatalf("expected non-nil CertPool") + } + + return pool.Subjects() +} + +func subjectPresent(subjects [][]byte, want []byte) bool { + for _, s := range subjects { + if bytes.Equal(s, want) { + return true + } + } + return false +} + +func TestConfigureTLS_CAFileOnly(t *testing.T) { + b := &Backend{} + + ca1 := mustMakeCA(t, "ca-one") + + dir := t.TempDir() + caPath := dir + "/ca.pem" + if err := os.WriteFile(caPath, ca1, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + client := retryablehttp.NewClient() + client.HTTPClient.Transport = &http.Transport{} + + cfg := cty.ObjectVal(map[string]cty.Value{ + "ca_file": cty.StringVal(caPath), + "skip_cert_verification": cty.False, + "client_ca_certificate_pem": cty.NullVal(cty.String), + "client_certificate_pem": cty.NullVal(cty.String), + "client_private_key_pem": cty.NullVal(cty.String), + }) + + if err := b.configureTLS(client, cfg); err != nil { + t.Fatalf("configureTLS: %v", err) + } + + tlsCfg := client.HTTPClient.Transport.(*http.Transport).TLSClientConfig + if tlsCfg == nil || tlsCfg.RootCAs == nil { + t.Fatalf("expected RootCAs to be configured") + } + + block, _ := pem.Decode(ca1) + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("ParseCertificate: %v", err) + } + + subjects := mustSubjects(t, tlsCfg.RootCAs) + if !subjectPresent(subjects, cert.RawSubject) { + t.Fatalf("expected CA subject from ca_file to be present in RootCAs") + } +} + +func TestConfigureTLS_CAFileAndInlinePEMMerge(t *testing.T) { + b := &Backend{} + + caFilePEM := mustMakeCA(t, "ca-file") + caInlinePEM := mustMakeCA(t, "ca-inline") + + dir := t.TempDir() + caPath := dir + "/ca.pem" + if err := os.WriteFile(caPath, caFilePEM, 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + client := retryablehttp.NewClient() + client.HTTPClient.Transport = &http.Transport{} + + cfg := cty.ObjectVal(map[string]cty.Value{ + "ca_file": cty.StringVal(caPath), + "client_ca_certificate_pem": cty.StringVal(string(caInlinePEM)), + "client_certificate_pem": cty.NullVal(cty.String), + "client_private_key_pem": cty.NullVal(cty.String), + "skip_cert_verification": cty.False, + }) + + if err := b.configureTLS(client, cfg); err != nil { + t.Fatalf("configureTLS: %v", err) + } + + tlsCfg := client.HTTPClient.Transport.(*http.Transport).TLSClientConfig + if tlsCfg == nil || tlsCfg.RootCAs == nil { + t.Fatalf("expected RootCAs to be configured") + } + + block1, _ := pem.Decode(caFilePEM) + cert1, err := x509.ParseCertificate(block1.Bytes) + if err != nil { + t.Fatalf("ParseCertificate file CA: %v", err) + } + + block2, _ := pem.Decode(caInlinePEM) + cert2, err := x509.ParseCertificate(block2.Bytes) + if err != nil { + t.Fatalf("ParseCertificate inline CA: %v", err) + } + + subjects := mustSubjects(t, tlsCfg.RootCAs) + + if !subjectPresent(subjects, cert1.RawSubject) { + t.Fatalf("expected file CA subject to be present in RootCAs") + } + if !subjectPresent(subjects, cert2.RawSubject) { + t.Fatalf("expected inline CA subject to be present in RootCAs") + } +} + +func TestConfigureTLS_CAFileNotFound(t *testing.T) { + b := &Backend{} + + client := retryablehttp.NewClient() + client.HTTPClient.Transport = &http.Transport{} + + cfg := cty.ObjectVal(map[string]cty.Value{ + "ca_file": cty.StringVal("/path/does/not/exist/ca.pem"), + "client_ca_certificate_pem": cty.NullVal(cty.String), + "client_certificate_pem": cty.NullVal(cty.String), + "client_private_key_pem": cty.NullVal(cty.String), + "skip_cert_verification": cty.False, + }) + + if err := b.configureTLS(client, cfg); err == nil { + t.Fatalf("expected error for missing ca_file, got nil") + } +}