From 90fcc2cf69e18cb627d23b706cf27c2c24186d1a Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 18 May 2026 16:33:10 +0100 Subject: [PATCH] fix: Prevent provider binary being placed outside of .terraform/providers cache unexpectedly due to use of symlinks. --- .changes/v1.15/BUG FIXES-20260518-164938.yaml | 5 ++ .../command/e2etest/providers_tamper_test.go | 52 +++++++++++++++++++ internal/providercache/package_install.go | 9 ++++ 3 files changed, 66 insertions(+) create mode 100644 .changes/v1.15/BUG FIXES-20260518-164938.yaml diff --git a/.changes/v1.15/BUG FIXES-20260518-164938.yaml b/.changes/v1.15/BUG FIXES-20260518-164938.yaml new file mode 100644 index 0000000000..344708eff7 --- /dev/null +++ b/.changes/v1.15/BUG FIXES-20260518-164938.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: 'init: Prevent provider binaries from being installed into symlinked directories' +time: 2026-05-18T16:49:38.375301+01:00 +custom: + Issue: "38611" diff --git a/internal/command/e2etest/providers_tamper_test.go b/internal/command/e2etest/providers_tamper_test.go index 1336088d60..ba912640b9 100644 --- a/internal/command/e2etest/providers_tamper_test.go +++ b/internal/command/e2etest/providers_tamper_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/e2e" "github.com/hashicorp/terraform/internal/getproviders" ) @@ -271,3 +272,54 @@ terraform { } } ` + +func TestSymlinkProviderTargetDirectory(t *testing.T) { + // This test reaches out to releases.hashicorp.com to download the + // null provider, so it can only run if network access is allowed. + skipIfCannotAccessNetwork(t) + + fixturePath := filepath.Join("testdata", "provider-tampering-base") + tf := e2e.NewBinary(t, terraformBin, fixturePath) + + // Create a directory that will be symlinked to inside the plugin cache directory. + // + // If the provider is downloaded without protections against symlinks present, + // the binary will be downloaded to ./other-dir, not the expected + // ./terraform/providers/registry.terraform.io/hashicorp/null/3.1.0// location + otherDir := tf.Path("other-dir") + err := os.MkdirAll(otherDir, 0755) + if err != nil { + t.Fatal(err) + } + + const providerVersion = "3.1.0" // must match the version in the fixture config + symlinkPathInCache := tf.Path( + ".terraform", + "providers", + addrs.DefaultProviderRegistryHost.String(), + "hashicorp", + "null", + providerVersion, + getproviders.CurrentPlatform.String(), + ) + + err = os.MkdirAll(filepath.Dir(symlinkPathInCache), 0755) + if err != nil { + t.Fatal(err) + } + err = os.Symlink(otherDir, symlinkPathInCache) + if err != nil { + t.Fatal(err) + } + + stdout, stderr, err := tf.Run("init", "-no-color") + if err == nil { + t.Fatalf("unexpected init success\nstdout:\n%s\nstderr:\n%s", stdout, stderr) + } + if !strings.Contains(stderr, "Failed to install provider") { + t.Fatalf("missing expected error message\nstderr:\n%s", stderr) + } + if !strings.Contains(stderr, "symlink") { + t.Fatalf("missing expected error message\nstderr:\n%s", stderr) + } +} diff --git a/internal/providercache/package_install.go b/internal/providercache/package_install.go index eea592ece4..ba9ec404b3 100644 --- a/internal/providercache/package_install.go +++ b/internal/providercache/package_install.go @@ -124,6 +124,15 @@ func installFromLocalArchive(ctx context.Context, meta getproviders.PackageMeta, // match the allowed hashes and so our caller should catch that after // we return if so. + // We will, however, check that the target directory the provider binary will be + // placed isn't a symlink, if it already exists. This could be a security risk that + // enables files to be written to unexpected locations. + if fi, err := os.Lstat(targetDir); err == nil { + if fi.Mode().Type() == os.ModeSymlink { + return authResult, fmt.Errorf("cannot install package into target directory %s because it is a symlink.", targetDir) + } + } + err := unzip.Decompress(targetDir, filename, true, 0000) if err != nil { return authResult, err