CVE-2020-12757
Description
HashiCorp Vault and Vault Enterprise 1.4.0 and 1.4.1, when configured with the GCP Secrets Engine, may incorrectly generate GCP Credentials with the default time-to-live lease duration instead of the engine-configured setting. This may lead to generated GCP credentials being valid for longer than intended. Fixed in 1.4.2.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Vault 1.4.0-1.4.1 GCP Secrets Engine uses default TTL instead of configured lease, extending credential validity.
Vulnerability
HashiCorp Vault versions 1.4.0 and 1.4.1, when configured with the GCP Secrets Engine, incorrectly generate GCP credentials using the default time-to-live (TTL) lease duration instead of the engine-configured setting [3]. This flaw originates in the credential generation logic, which fails to apply the specified TTL during key or token creation [1][2].
Exploitation
An attacker with the ability to request GCP secrets from Vault—whether through legitimate access or compromised credentials—can obtain service account keys or access tokens that remain valid for longer than intended. The bug does not require authentication bypass; it affects all authorized users of the GCP Secrets Engine during the vulnerable versions [3].
Impact
Extended lease durations increase the risk of credential misuse, as generated credentials persist beyond their expected lifetime. If an attacker captures such credentials, they gain prolonged unauthorized access to associated GCP resources, potentially leading to data exposure or resource manipulation [3].
Mitigation
The vulnerability is fixed in Vault 1.4.2, as noted in the official changelog [4]. Users are strongly advised to upgrade to version 1.4.2 or later. No documented workarounds exist for this issue [1][4].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/hashicorp/vault-plugin-secrets-gcpGo | < 0.6.2 | 0.6.2 |
Affected products
3- HashiCorp/Vaultdescription
- osv-coords2 versions
>= 1.4.0, < 1.4.2+ 1 more
- (no CPE)range: >= 1.4.0, < 1.4.2
- (no CPE)range: < 0.6.2
Patches
1e43d20870c50bug: service account key should use config ttl (#85)
4 files changed · +192 −21
Makefile+2 −2 modified@@ -29,10 +29,10 @@ testcompile: fmtcheck generate done test: - @go test -short -parallel=40 ./... + @go test -short -parallel=40 ./... $(TESTARGS) test-acc: - @go test -parallel=40 $(TESTARGS) + @go test -parallel=40 ./... $(TESTARGS) # generate runs `go generate` to build the dynamically generated # source files. generate:
plugin/path_role_set_test.go+10 −6 modified@@ -24,7 +24,7 @@ func TestPathRoleSet_Basic(t *testing.T) { "roles/viewer": struct{}{}, } - td := setupTest(t) + td := setupTest(t, "0s", "2h") defer cleanup(t, td, rsName, roles) projRes := fmt.Sprintf(testProjectResourceTemplate, td.Project) @@ -53,6 +53,7 @@ func TestPathRoleSet_Basic(t *testing.T) { if respData == nil { t.Fatalf("expected role set to have been created") } + verifyReadData(t, respData, map[string]interface{}{ "secret_type": SecretTypeAccessToken, // default "project": td.Project, @@ -79,7 +80,7 @@ func TestPathRoleSet_UpdateKeyRoleSet(t *testing.T) { } // Initial test set up - backend, initial config, test resources in project - td := setupTest(t) + td := setupTest(t, "0s", "2h") defer cleanup(t, td, rsName, initRoles.Union(updatedRoles)) projRes := fmt.Sprintf(testProjectResourceTemplate, td.Project) @@ -179,7 +180,7 @@ func TestPathRoleSet_RotateKeyRoleSet(t *testing.T) { } // Initial test set up - backend, initial config, test resources in project - td := setupTest(t) + td := setupTest(t, "0s", "2h") defer cleanup(t, td, rsName, roles) projRes := fmt.Sprintf(testProjectResourceTemplate, td.Project) @@ -239,7 +240,7 @@ func TestPathRoleSet_UpdateTokenRoleSet(t *testing.T) { } // Initial test set up - backend, initial config, test resources in project - td := setupTest(t) + td := setupTest(t, "0s", "2h") defer cleanup(t, td, rsName, initRoles.Union(updatedRoles)) projRes := fmt.Sprintf(testProjectResourceTemplate, td.Project) @@ -333,7 +334,7 @@ func TestPathRoleSet_RotateTokenRoleSet(t *testing.T) { } // Initial test set up - backend, initial config, test resources in project - td := setupTest(t) + td := setupTest(t, "0s", "2h") defer cleanup(t, td, rsName, roles) projRes := fmt.Sprintf(testProjectResourceTemplate, td.Project) @@ -657,7 +658,7 @@ type testData struct { IamAdmin *iam.Service } -func setupTest(t *testing.T) *testData { +func setupTest(t *testing.T, ttl, maxTTL string) *testData { proj := util.GetTestProject(t) credsJson, creds := util.GetTestCredentials(t) httpC, err := gcputil.GetHttpClient(creds, iam.CloudPlatformScope) @@ -671,8 +672,11 @@ func setupTest(t *testing.T) *testData { } b, reqStorage := getTestBackend(t) + testConfigUpdate(t, b, reqStorage, map[string]interface{}{ "credentials": credsJson, + "ttl": ttl, + "max_ttl": maxTTL, }) return &testData{
plugin/secrets_service_account_key.go+5 −1 modified@@ -58,7 +58,7 @@ func pathSecretServiceAccountKey(b *backend) *framework.Path { Description: fmt.Sprintf(`Private key type for service account key - defaults to %s"`, privateKeyTypeJson), Default: privateKeyTypeJson, }, - "ttl": &framework.FieldSchema{ + "ttl": { Type: framework.TypeDurationSecond, Description: "Lifetime of the service account key", }, @@ -215,6 +215,10 @@ func (b *backend) getSecretKey(ctx context.Context, s logical.Storage, rs *RoleS resp := b.Secret(SecretTypeKey).Response(secretD, internalD) resp.Secret.Renewable = true + resp.Secret.MaxTTL = cfg.MaxTTL + resp.Secret.TTL = cfg.TTL + + // If the request came with a TTL value, overwrite the config default if ttl > 0 { resp.Secret.TTL = time.Duration(ttl) * time.Second }
plugin/secrets_test.go+175 −12 modified@@ -33,7 +33,7 @@ func TestSecrets_GenerateAccessToken(t *testing.T) { secretType := SecretTypeAccessToken rsName := "test-gentoken" - td := setupTest(t) + td := setupTest(t, "0s", "2h") defer cleanup(t, td, rsName, testRoles) projRes := fmt.Sprintf(testProjectResourceTemplate, td.Project) @@ -69,11 +69,11 @@ func TestSecrets_GenerateAccessToken(t *testing.T) { verifyProjectBindingsRemoved(t, td, sa.Email, testRoles) } -func TestSecrets_GenerateKey(t *testing.T) { +func TestSecrets_GenerateKeyConfigTTL(t *testing.T) { secretType := SecretTypeKey rsName := "test-genkey" - td := setupTest(t) + td := setupTest(t, "1h", "2h") defer cleanup(t, td, rsName, testRoles) projRes := fmt.Sprintf(testProjectResourceTemplate, td.Project) @@ -95,13 +95,148 @@ func TestSecrets_GenerateKey(t *testing.T) { // expect error for trying to read token from key roleset testGetTokenFail(t, td, rsName) - creds, secret := testGetKey(t, td, rsName) + creds, resp := testGetKey(t, td, rsName) + if int(resp.Secret.LeaseTotal().Hours()) != 1 { + t.Fatalf("expected lease duration %d, got %d", 1, int(resp.Secret.LeaseTotal().Hours())) + } + + // Confirm calls with key work + keyHttpC := oauth2.NewClient(context.Background(), creds.TokenSource) + checkSecretPermissions(t, td, keyHttpC) + + keyName := resp.Secret.InternalData["key_name"].(string) + if keyName == "" { + t.Fatalf("expected internal data to include key name") + } + + _, err = td.IamAdmin.Projects.ServiceAccounts.Keys.Get(keyName).Do() + if err != nil { + t.Fatalf("could not get key from given internal 'key_name': %v", err) + } + + testRenewSecretKey(t, td, resp.Secret) + testRevokeSecretKey(t, td, resp.Secret) + + k, err := td.IamAdmin.Projects.ServiceAccounts.Keys.Get(keyName).Do() + + if k != nil { + t.Fatalf("expected error as revoked key was deleted, instead got key: %v", k) + } + if err == nil || !isGoogleAccountKeyNotFoundErr(err) { + t.Fatalf("expected 404 error from getting deleted key, instead got error: %v", err) + } + + // Cleanup: Delete role set + testRoleSetDelete(t, td, rsName, sa.Name) + verifyProjectBindingsRemoved(t, td, sa.Email, testRoles) +} + +func TestSecrets_GenerateKeyTTLOverride(t *testing.T) { + secretType := SecretTypeKey + rsName := "test-genkey" + + td := setupTest(t, "1h", "2h") + defer cleanup(t, td, rsName, testRoles) + + projRes := fmt.Sprintf(testProjectResourceTemplate, td.Project) + + // Create new role set + expectedBinds := ResourceBindings{projRes: testRoles} + bindsRaw, err := util.BindingsHCL(expectedBinds) + if err != nil { + t.Fatalf("unable to convert resource bindings to HCL string: %v", err) + } + testRoleSetCreate(t, td, rsName, + map[string]interface{}{ + "secret_type": secretType, + "project": td.Project, + "bindings": bindsRaw, + }) + sa := getRoleSetAccount(t, td, rsName) + + // expect error for trying to read token from key roleset + testGetTokenFail(t, td, rsName) + + // call the POST endpoint of /gcp/key/:roleset with updated TTL + creds, resp := testPostKey(t, td, rsName, "60s") + if int(resp.Secret.LeaseTotal().Seconds()) != 60 { + t.Fatalf("expected lease duration %d, got %d", 60, int(resp.Secret.LeaseTotal().Seconds())) + } + + // Confirm calls with key work + keyHttpC := oauth2.NewClient(context.Background(), creds.TokenSource) + checkSecretPermissions(t, td, keyHttpC) + + keyName := resp.Secret.InternalData["key_name"].(string) + if keyName == "" { + t.Fatalf("expected internal data to include key name") + } + + _, err = td.IamAdmin.Projects.ServiceAccounts.Keys.Get(keyName).Do() + if err != nil { + t.Fatalf("could not get key from given internal 'key_name': %v", err) + } + + testRenewSecretKey(t, td, resp.Secret) + testRevokeSecretKey(t, td, resp.Secret) + + k, err := td.IamAdmin.Projects.ServiceAccounts.Keys.Get(keyName).Do() + + if k != nil { + t.Fatalf("expected error as revoked key was deleted, instead got key: %v", k) + } + if err == nil || !isGoogleAccountKeyNotFoundErr(err) { + t.Fatalf("expected 404 error from getting deleted key, instead got error: %v", err) + } + + // Cleanup: Delete role set + testRoleSetDelete(t, td, rsName, sa.Name) + verifyProjectBindingsRemoved(t, td, sa.Email, testRoles) +} + +// TestSecrets_GenerateKeyMaxTTLCheck verifies the MaxTTL is set for the +// configured backend +func TestSecrets_GenerateKeyMaxTTLCheck(t *testing.T) { + secretType := SecretTypeKey + rsName := "test-genkey" + + td := setupTest(t, "1h", "2h") + defer cleanup(t, td, rsName, testRoles) + + projRes := fmt.Sprintf(testProjectResourceTemplate, td.Project) + + // Create new role set + expectedBinds := ResourceBindings{projRes: testRoles} + bindsRaw, err := util.BindingsHCL(expectedBinds) + if err != nil { + t.Fatalf("unable to convert resource bindings to HCL string: %v", err) + } + testRoleSetCreate(t, td, rsName, + map[string]interface{}{ + "secret_type": secretType, + "project": td.Project, + "bindings": bindsRaw, + }) + sa := getRoleSetAccount(t, td, rsName) + + // expect error for trying to read token from key roleset + testGetTokenFail(t, td, rsName) + + // call the POST endpoint of /gcp/key/:roleset with updated TTL + creds, resp := testPostKey(t, td, rsName, "60s") + if int(resp.Secret.LeaseTotal().Seconds()) != 60 { + t.Fatalf("expected lease duration %d, got %d", 60, int(resp.Secret.LeaseTotal().Seconds())) + } + + if int(resp.Secret.LeaseOptions.MaxTTL.Hours()) != 2 { + t.Fatalf("expected max lease %d, got %d", 2, int(resp.Secret.LeaseOptions.MaxTTL.Hours())) + } // Confirm calls with key work keyHttpC := oauth2.NewClient(context.Background(), creds.TokenSource) checkSecretPermissions(t, td, keyHttpC) - keyName := secret.InternalData["key_name"].(string) + keyName := resp.Secret.InternalData["key_name"].(string) if keyName == "" { t.Fatalf("expected internal data to include key name") } @@ -111,8 +246,8 @@ func TestSecrets_GenerateKey(t *testing.T) { t.Fatalf("could not get key from given internal 'key_name': %v", err) } - testRenewSecretKey(t, td, secret) - testRevokeSecretKey(t, td, secret) + testRenewSecretKey(t, td, resp.Secret) + testRevokeSecretKey(t, td, resp.Secret) k, err := td.IamAdmin.Projects.ServiceAccounts.Keys.Get(keyName).Do() @@ -211,11 +346,18 @@ func testGetToken(t *testing.T, td *testData, rsName string) (token string) { return tokenRaw.(string) } -func testGetKey(t *testing.T, td *testData, rsName string) (*google.Credentials, *logical.Secret) { +// testPostKey enables the POST call to /gcp/key/:roleset +func testPostKey(t *testing.T, td *testData, rsName, ttl string) (*google.Credentials, *logical.Response) { + data := map[string]interface{}{} + if ttl != "" { + data["ttl"] = ttl + } + resp, err := td.B.HandleRequest(context.Background(), &logical.Request{ - Operation: logical.ReadOperation, + Operation: logical.UpdateOperation, Path: fmt.Sprintf("key/%s", rsName), Storage: td.S, + Data: data, }) if err != nil { @@ -227,12 +369,33 @@ func testGetKey(t *testing.T, td *testData, rsName string) (*google.Credentials, if resp == nil || resp.Secret == nil { t.Fatalf("expected response with secret, got response: %v", resp) } - if resp.Secret.ExpirationTime().Sub(resp.Secret.IssueTime) > defaultLeaseTTLHr*time.Hour { - t.Fatalf("unexpected lease duration is longer than backend default") + + creds := getGoogleCredentials(t, resp.Data) + return creds, resp +} + +func testGetKey(t *testing.T, td *testData, rsName string) (*google.Credentials, *logical.Response) { + data := map[string]interface{}{} + + resp, err := td.B.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Path: fmt.Sprintf("key/%s", rsName), + Storage: td.S, + Data: data, + }) + + if err != nil { + t.Fatal(err) + } + if resp != nil && resp.IsError() { + t.Fatal(resp.Error()) + } + if resp == nil || resp.Secret == nil { + t.Fatalf("expected response with secret, got response: %v", resp) } creds := getGoogleCredentials(t, resp.Data) - return creds, resp.Secret + return creds, resp } func testRenewSecretKey(t *testing.T, td *testData, sec *logical.Secret) {
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-75pc-qvwc-jf3gghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2020-12757ghsaADVISORY
- github.com/hashicorp/vault-plugin-secrets-gcp/commit/e43d20870c50f7428dead1411debcec075b35fb4ghsaWEB
- github.com/hashicorp/vault-plugin-secrets-gcp/pull/85ghsaWEB
- github.com/hashicorp/vault/blob/master/CHANGELOG.mdghsaWEB
- github.com/hashicorp/vault/blob/master/CHANGELOG.mdghsax_refsource_MISCWEB
- www.hashicorp.com/blog/category/vaultghsaWEB
- www.hashicorp.com/blog/category/vault/mitrex_refsource_CONFIRM
News mentions
0No linked articles in our index yet.