From dd707e9e3ac38b8b3fcb95e2dc569036d0136053 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 10 Jun 2026 22:47:58 -0700 Subject: [PATCH 1/3] fix(secrets): migrate to GA Azure Key Vault azsecrets module Replace the retired github.com/Azure/azure-sdk-for-go/sdk/keyvault/ azsecrets v0.12.0 beta module with the GA replacement sdk/security/keyvault/azsecrets v1.4.0 on the secrets-resolution path. Microsoft retired the whole sdk/keyvault/* beta tree in 2023, so the old module receives no bug or security fixes despite sitting on the path that fetches admin passwords, API keys, and encryption keys. Changes: - internal/secrets/azure_resolver.go: switch the import to the GA module and rename NewListSecretsPager to NewListSecretPropertiesPager per the GA API; GetSecret/SetSecret signatures are unchanged. - Tests: adopt the GA type names (SecretProperties, Secret, ListSecretPropertiesResponse/Options, SecretPropertiesListResult). - The httptest harness now uses httptest.NewTLSServer with the test server's client as transport, because the GA challenge policy only attaches credentials over TLS-protected connections. - go.mod/go.sum: drop the retired sdk/keyvault/azsecrets v0.12.0 and transitive sdk/keyvault/internal v0.7.1; v1.4.0 matches the azkeys version already in use and requires no other dependency bumps. - Add a regression guard test that fails if any retired sdk/keyvault/* module reappears in go.mod (verified failing on the pre-fix go.mod). Closes #1146 --- go.mod | 3 +- go.sum | 6 +-- internal/secrets/azure_resolver.go | 4 +- .../secrets/azure_resolver_coverage_test.go | 8 ++-- .../secrets/azure_resolver_httptest_test.go | 11 +++-- internal/secrets/azure_resolver_test.go | 44 +++++++++---------- .../secrets/azure_sdk_module_guard_test.go | 35 +++++++++++++++ 7 files changed, 73 insertions(+), 38 deletions(-) create mode 100644 internal/secrets/azure_sdk_module_guard_test.go diff --git a/go.mod b/go.mod index 56e8cadd0..2a11d4f6f 100644 --- a/go.mod +++ b/go.mod @@ -83,10 +83,10 @@ require ( require ( cloud.google.com/go/kms v1.29.0 cloud.google.com/go/secretmanager v1.16.0 - github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/billingbenefits/armbillingbenefits v1.0.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/reservations/armreservations v1.1.0 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 github.com/LeanerCloud/CUDly/pkg v0.0.0 github.com/LeanerCloud/CUDly/providers/aws v0.0.0 github.com/LeanerCloud/CUDly/providers/azure v0.0.0 @@ -122,7 +122,6 @@ require ( cloud.google.com/go/redis v1.18.3 // indirect cloud.google.com/go/storage v1.56.0 // indirect dario.cat/mergo v1.0.2 // indirect - github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cosmos/armcosmos/v2 v2.7.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/search/armsearch v1.4.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect diff --git a/go.sum b/go.sum index 9c55d4896..a6710c69b 100644 --- a/go.sum +++ b/go.sum @@ -44,10 +44,6 @@ github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+ github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA= -github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0 h1:xnO4sFyG8UH2fElBkcqLTOZsAajvKfnSlgBBW8dXYjw= -github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets v0.12.0/go.mod h1:XD3DIOOVgBCO03OleB1fHjgktVRFxlT++KwKgIOewdM= -github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= -github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1/go.mod h1:9V2j0jn9jDEkCkv8w/bKTNppX/d0FVA1ud77xCIP4KA= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/advisor/armadvisor v1.2.0 h1:3ddjPq/3A/oB2u7LdohEr900EGP5l1MnAiNc3EbY1E4= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/advisor/armadvisor v1.2.0/go.mod h1:oZ73p8dR7aZI+TJo5Ul92oCoVubMYPBo39eTsWa0AiQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/billingbenefits/armbillingbenefits v1.0.0 h1:4JHm1qT4VKYZ8aGP1dJBg+wk/WgN/KitLNtQs6jFNfA= @@ -78,6 +74,8 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0 h1:S087d github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql v1.2.0/go.mod h1:B4cEyXrWBmbfMDAPnpJ1di7MAt5DKP57jPEObAvZChg= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 h1:/g8S6wk65vfC6m3FIxJ+i5QDyN9JWwXI8Hb0Img10hU= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0/go.mod h1:gpl+q95AzZlKVI3xSoseF9QPrypk0hQqBiJYeB/cR/I= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= diff --git a/internal/secrets/azure_resolver.go b/internal/secrets/azure_resolver.go index c7aca0e31..a71c82d33 100644 --- a/internal/secrets/azure_resolver.go +++ b/internal/secrets/azure_resolver.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" - "github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" ) // AzureResolver implements Resolver for Azure Key Vault. @@ -88,7 +88,7 @@ func (r *AzureResolver) ListSecrets(ctx context.Context, filter string) ([]strin secrets := make([]string, 0) // Create pager for listing secrets - pager := r.client.NewListSecretsPager(nil) + pager := r.client.NewListSecretPropertiesPager(nil) for pager.More() { page, err := pager.NextPage(ctx) diff --git a/internal/secrets/azure_resolver_coverage_test.go b/internal/secrets/azure_resolver_coverage_test.go index be8fe9e55..2b4cdb6e0 100644 --- a/internal/secrets/azure_resolver_coverage_test.go +++ b/internal/secrets/azure_resolver_coverage_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -286,7 +286,7 @@ func TestMockSecretID_EdgeCases(t *testing.T) { // TestMockAzureSecretsPager_MultiplePages tests the mock pager with multiple pages. func TestMockAzureSecretsPager_MultiplePages(t *testing.T) { pager := &MockAzureSecretsPager{ - pages: [][]*azsecrets.SecretItem{ + pages: [][]*azsecrets.SecretProperties{ {}, // empty first page {}, // empty second page }, @@ -319,7 +319,7 @@ func TestMockAzureSecretsPager_Error(t *testing.T) { // TestMockAzureSecretsPager_EmptyPages tests the mock pager with empty pages slice. func TestMockAzureSecretsPager_EmptyPages(t *testing.T) { pager := &MockAzureSecretsPager{ - pages: [][]*azsecrets.SecretItem{}, + pages: [][]*azsecrets.SecretProperties{}, } assert.False(t, pager.More()) @@ -328,7 +328,7 @@ func TestMockAzureSecretsPager_EmptyPages(t *testing.T) { // TestMockAzureSecretsPager_NoMorePages tests NextPage when there are no more pages. func TestMockAzureSecretsPager_NoMorePages(t *testing.T) { pager := &MockAzureSecretsPager{ - pages: [][]*azsecrets.SecretItem{{}}, + pages: [][]*azsecrets.SecretProperties{{}}, currentPage: 1, // Already past the first page } diff --git a/internal/secrets/azure_resolver_httptest_test.go b/internal/secrets/azure_resolver_httptest_test.go index 812eeeb87..37254abfd 100644 --- a/internal/secrets/azure_resolver_httptest_test.go +++ b/internal/secrets/azure_resolver_httptest_test.go @@ -12,7 +12,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" - "github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -46,17 +46,20 @@ func azureTestHandler(handler http.HandlerFunc) http.HandlerFunc { } } -// newTestAzureResolver creates an AzureResolver backed by a mock HTTP server. +// newTestAzureResolver creates an AzureResolver backed by a mock HTTPS server. +// TLS is required because the GA Key Vault challenge policy only attaches +// credentials over TLS-protected connections; server.Client() supplies a +// transport that trusts the test server's certificate. // The handler must NOT reference the server variable (it is created inside this function). func newTestAzureResolver(t *testing.T, handler http.HandlerFunc) (*AzureResolver, *httptest.Server) { t.Helper() - server := httptest.NewServer(azureTestHandler(handler)) + server := httptest.NewTLSServer(azureTestHandler(handler)) cred := &fakeTokenCredential{} client, err := azsecrets.NewClient(server.URL, cred, &azsecrets.ClientOptions{ ClientOptions: policy.ClientOptions{ - InsecureAllowCredentialWithHTTP: true, + Transport: server.Client(), Retry: policy.RetryOptions{ MaxRetries: 0, }, diff --git a/internal/secrets/azure_resolver_test.go b/internal/secrets/azure_resolver_test.go index c135714d1..3cb2d81d1 100644 --- a/internal/secrets/azure_resolver_test.go +++ b/internal/secrets/azure_resolver_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/Azure/azure-sdk-for-go/sdk/keyvault/azsecrets" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -32,7 +32,7 @@ func (m MockSecretID) Version() string { // MockAzureSecretsPager simulates the Azure secrets pager. type MockAzureSecretsPager struct { - pages [][]*azsecrets.SecretItem + pages [][]*azsecrets.SecretProperties currentPage int err error } @@ -45,18 +45,18 @@ func (m *MockAzureSecretsPager) More() bool { return m.currentPage < len(m.pages) } -func (m *MockAzureSecretsPager) NextPage(ctx context.Context) (azsecrets.ListSecretsResponse, error) { +func (m *MockAzureSecretsPager) NextPage(ctx context.Context) (azsecrets.ListSecretPropertiesResponse, error) { if m.err != nil { - return azsecrets.ListSecretsResponse{}, m.err + return azsecrets.ListSecretPropertiesResponse{}, m.err } if m.currentPage >= len(m.pages) { - return azsecrets.ListSecretsResponse{}, errors.New("no more pages") + return azsecrets.ListSecretPropertiesResponse{}, errors.New("no more pages") } page := m.pages[m.currentPage] m.currentPage++ - return azsecrets.ListSecretsResponse{ - SecretListResult: azsecrets.SecretListResult{ + return azsecrets.ListSecretPropertiesResponse{ + SecretPropertiesListResult: azsecrets.SecretPropertiesListResult{ Value: page, }, }, nil @@ -72,7 +72,7 @@ func (m *MockAzureSecretsClient) GetSecret(ctx context.Context, name string, ver return args.Get(0).(azsecrets.GetSecretResponse), args.Error(1) } -func (m *MockAzureSecretsClient) NewListSecretsPager(options *azsecrets.ListSecretsOptions) *MockAzureSecretsPager { +func (m *MockAzureSecretsClient) NewListSecretPropertiesPager(options *azsecrets.ListSecretPropertiesOptions) *MockAzureSecretsPager { args := m.Called(options) return args.Get(0).(*MockAzureSecretsPager) } @@ -113,7 +113,7 @@ func (r *testableAzureResolver) GetSecretJSON(ctx context.Context, secretID stri func (r *testableAzureResolver) ListSecrets(ctx context.Context, filter string) ([]string, error) { secrets := make([]string, 0) - pager := r.mockClient.NewListSecretsPager(nil) + pager := r.mockClient.NewListSecretPropertiesPager(nil) for pager.More() { page, err := pager.NextPage(ctx) @@ -154,7 +154,7 @@ func TestAzureResolver_GetSecret_Success(t *testing.T) { secretValue := "my-azure-secret-value" mockClient.On("GetSecret", ctx, "test-secret", "", (*azsecrets.GetSecretOptions)(nil)).Return( azsecrets.GetSecretResponse{ - SecretBundle: azsecrets.SecretBundle{ + Secret: azsecrets.Secret{ Value: &secretValue, }, }, nil, @@ -191,7 +191,7 @@ func TestAzureResolver_GetSecret_NilValue(t *testing.T) { mockClient.On("GetSecret", ctx, "nil-secret", "", (*azsecrets.GetSecretOptions)(nil)).Return( azsecrets.GetSecretResponse{ - SecretBundle: azsecrets.SecretBundle{ + Secret: azsecrets.Secret{ Value: nil, }, }, nil, @@ -213,7 +213,7 @@ func TestAzureResolver_GetSecretJSON_Success(t *testing.T) { jsonSecret := `{"username":"azure-user","password":"azure-password","timeout":30}` mockClient.On("GetSecret", ctx, "json-secret", "", (*azsecrets.GetSecretOptions)(nil)).Return( azsecrets.GetSecretResponse{ - SecretBundle: azsecrets.SecretBundle{ + Secret: azsecrets.Secret{ Value: &jsonSecret, }, }, nil, @@ -237,7 +237,7 @@ func TestAzureResolver_GetSecretJSON_InvalidJSON(t *testing.T) { invalidJSON := "not-valid-json" mockClient.On("GetSecret", ctx, "invalid-json", "", (*azsecrets.GetSecretOptions)(nil)).Return( azsecrets.GetSecretResponse{ - SecretBundle: azsecrets.SecretBundle{ + Secret: azsecrets.Secret{ Value: &invalidJSON, }, }, nil, @@ -277,7 +277,7 @@ func TestAzureResolver_ListSecrets_Success(t *testing.T) { id3 := azsecrets.ID(MockSecretID("https://myvault.vault.azure.net/secrets/secret-3")) pager := &MockAzureSecretsPager{ - pages: [][]*azsecrets.SecretItem{ + pages: [][]*azsecrets.SecretProperties{ { {ID: &id1}, {ID: &id2}, @@ -288,7 +288,7 @@ func TestAzureResolver_ListSecrets_Success(t *testing.T) { }, } - mockClient.On("NewListSecretsPager", (*azsecrets.ListSecretsOptions)(nil)).Return(pager) + mockClient.On("NewListSecretPropertiesPager", (*azsecrets.ListSecretPropertiesOptions)(nil)).Return(pager) result, err := resolver.ListSecrets(ctx, "") @@ -310,7 +310,7 @@ func TestAzureResolver_ListSecrets_WithFilter(t *testing.T) { id3 := azsecrets.ID(MockSecretID("https://myvault.vault.azure.net/secrets/prod-api-key")) pager := &MockAzureSecretsPager{ - pages: [][]*azsecrets.SecretItem{ + pages: [][]*azsecrets.SecretProperties{ { {ID: &id1}, {ID: &id2}, @@ -319,7 +319,7 @@ func TestAzureResolver_ListSecrets_WithFilter(t *testing.T) { }, } - mockClient.On("NewListSecretsPager", (*azsecrets.ListSecretsOptions)(nil)).Return(pager) + mockClient.On("NewListSecretPropertiesPager", (*azsecrets.ListSecretPropertiesOptions)(nil)).Return(pager) result, err := resolver.ListSecrets(ctx, "prod") @@ -340,7 +340,7 @@ func TestAzureResolver_ListSecrets_Error(t *testing.T) { err: errors.New("permission denied"), } - mockClient.On("NewListSecretsPager", (*azsecrets.ListSecretsOptions)(nil)).Return(pager) + mockClient.On("NewListSecretPropertiesPager", (*azsecrets.ListSecretPropertiesOptions)(nil)).Return(pager) result, err := resolver.ListSecrets(ctx, "") @@ -356,10 +356,10 @@ func TestAzureResolver_ListSecrets_Empty(t *testing.T) { resolver := &testableAzureResolver{mockClient: mockClient, vaultURL: "https://myvault.vault.azure.net/"} pager := &MockAzureSecretsPager{ - pages: [][]*azsecrets.SecretItem{}, + pages: [][]*azsecrets.SecretProperties{}, } - mockClient.On("NewListSecretsPager", (*azsecrets.ListSecretsOptions)(nil)).Return(pager) + mockClient.On("NewListSecretPropertiesPager", (*azsecrets.ListSecretPropertiesOptions)(nil)).Return(pager) result, err := resolver.ListSecrets(ctx, "") @@ -376,7 +376,7 @@ func TestAzureResolver_ListSecrets_NilID(t *testing.T) { id1 := azsecrets.ID(MockSecretID("https://myvault.vault.azure.net/secrets/valid-secret")) pager := &MockAzureSecretsPager{ - pages: [][]*azsecrets.SecretItem{ + pages: [][]*azsecrets.SecretProperties{ { {ID: &id1}, {ID: nil}, // nil ID should be skipped @@ -384,7 +384,7 @@ func TestAzureResolver_ListSecrets_NilID(t *testing.T) { }, } - mockClient.On("NewListSecretsPager", (*azsecrets.ListSecretsOptions)(nil)).Return(pager) + mockClient.On("NewListSecretPropertiesPager", (*azsecrets.ListSecretPropertiesOptions)(nil)).Return(pager) result, err := resolver.ListSecrets(ctx, "") diff --git a/internal/secrets/azure_sdk_module_guard_test.go b/internal/secrets/azure_sdk_module_guard_test.go new file mode 100644 index 000000000..c832f47a1 --- /dev/null +++ b/internal/secrets/azure_sdk_module_guard_test.go @@ -0,0 +1,35 @@ +package secrets + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// retiredKeyVaultModulePrefix is the module path prefix of the Azure Key Vault +// SDK beta tree that Microsoft retired in 2023 in favor of +// github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/*. The retired tree +// receives no bug or security fixes, so it must never reappear on the +// secrets-resolution path (SUP-01, issue #1146). +const retiredKeyVaultModulePrefix = "github.com/Azure/azure-sdk-for-go/sdk/keyvault/" + +// TestNoRetiredAzureKeyVaultModule guards against reintroducing the retired +// sdk/keyvault/* module tree. Before the SUP-01 fix, go.mod required +// sdk/keyvault/azsecrets v0.12.0 (plus sdk/keyvault/internal v0.7.1), which +// this test fails on; after migrating to sdk/security/keyvault/azsecrets it +// passes. +func TestNoRetiredAzureKeyVaultModule(t *testing.T) { + // Tests run with the package directory as the working directory, so the + // root go.mod is two levels up. + data, err := os.ReadFile("../../go.mod") + require.NoError(t, err, "failed to read root go.mod") + + for i, line := range strings.Split(string(data), "\n") { + require.NotContains(t, line, retiredKeyVaultModulePrefix, + "go.mod line %d references the retired Azure Key Vault SDK tree %q; "+ + "use github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/* instead", + i+1, retiredKeyVaultModulePrefix) + } +} From 07ed4864d02dd15ec996419869f18230c4cd44de Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 19 Jun 2026 17:40:11 +0200 Subject: [PATCH 2/3] fix(secrets): add IMDS-blocking transport to NewAzureResolver The azsecrets.NewClient call passed nil options, using the default net/http transport with no SSRF protection. An attacker-controlled URL redirect could reach the Azure link-local metadata endpoint (169.254.169.254) and exfiltrate managed-identity credentials. Wire an IMDS-blocking transport (blockIMDSDialer) into azsecrets.ClientOptions.Transport in NewAzureResolver, mirroring the defense already applied in providers/azure/internal/httpclient. A new TestAzureResolver_IMDSBlocked test asserts the block is active. Closes: feedback_azure_use_httpclient_new (SUP-01 / issue #1146) --- internal/secrets/azure_resolver.go | 62 ++++++++++++++++++- .../secrets/azure_resolver_httptest_test.go | 17 +++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/internal/secrets/azure_resolver.go b/internal/secrets/azure_resolver.go index a71c82d33..5916b411d 100644 --- a/internal/secrets/azure_resolver.go +++ b/internal/secrets/azure_resolver.go @@ -4,12 +4,61 @@ import ( "context" "encoding/json" "fmt" + "net" + "net/http" "strings" + "time" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" ) +// imdsAddresses are the well-known metadata service endpoints that must never +// be reachable from application-level HTTP clients. A request that reaches +// these addresses from user-supplied input is an SSRF attack that can leak +// managed-identity credentials. +var imdsAddresses = map[string]bool{ + "169.254.169.254": true, // Azure/AWS/GCP link-local IMDS (IPv4) + "fd00:ec2::254": true, // AWS IMDS (IPv6) +} + +// blockIMDSDialer wraps net.Dialer and rejects outbound connections to IMDS +// addresses before a TCP handshake is attempted. +type blockIMDSDialer struct { + inner net.Dialer +} + +func (d *blockIMDSDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + if imdsAddresses[host] { + return nil, fmt.Errorf("connection to metadata endpoint %s is blocked", host) + } + return d.inner.DialContext(ctx, network, addr) +} + +// imdsBlockingTransport returns an *http.Client whose transport rejects +// connections to Azure/AWS/GCP IMDS link-local addresses, mitigating SSRF +// attacks that could exfiltrate managed-identity credentials. +func imdsBlockingTransport() *http.Client { + dialer := &blockIMDSDialer{ + inner: net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }, + } + return &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + DialContext: dialer.DialContext, + TLSHandshakeTimeout: 10 * time.Second, + }, + } +} + // AzureResolver implements Resolver for Azure Key Vault. type AzureResolver struct { client *azsecrets.Client @@ -17,6 +66,8 @@ type AzureResolver struct { } // NewAzureResolver creates a new Azure Key Vault resolver. +// The underlying HTTP client blocks connections to IMDS link-local addresses +// (169.254.169.254, fd00:ec2::254) to mitigate SSRF attacks. func NewAzureResolver(ctx context.Context, vaultURL string) (*AzureResolver, error) { // Create a credential using DefaultAzureCredential. // Note: azidentity.NewDefaultAzureCredential does not accept a context parameter, @@ -27,8 +78,15 @@ func NewAzureResolver(ctx context.Context, vaultURL string) (*AzureResolver, err return nil, fmt.Errorf("failed to create Azure credential: %w", err) } - // Create Key Vault client - client, err := azsecrets.NewClient(vaultURL, cred, nil) + // Wire the IMDS-blocking transport so that SSRF via a redirected URL + // cannot reach the metadata endpoint and leak managed-identity tokens. + opts := &azsecrets.ClientOptions{ + ClientOptions: policy.ClientOptions{ + Transport: imdsBlockingTransport(), + }, + } + + client, err := azsecrets.NewClient(vaultURL, cred, opts) if err != nil { return nil, fmt.Errorf("failed to create Azure Key Vault client: %w", err) } diff --git a/internal/secrets/azure_resolver_httptest_test.go b/internal/secrets/azure_resolver_httptest_test.go index 37254abfd..42bfbde17 100644 --- a/internal/secrets/azure_resolver_httptest_test.go +++ b/internal/secrets/azure_resolver_httptest_test.go @@ -288,3 +288,20 @@ func TestAzureResolverReal_Close(t *testing.T) { err = resolver.Close() assert.NoError(t, err) } + +// TestAzureResolver_IMDSBlocked verifies that the IMDS-blocking transport +// installed by NewAzureResolver prevents outbound connections to the Azure/AWS +// link-local metadata endpoint (169.254.169.254). Without this guard an +// attacker-controlled redirect could leak managed-identity credentials. +func TestAzureResolver_IMDSBlocked(t *testing.T) { + client := imdsBlockingTransport() + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, + "http://169.254.169.254/metadata/instance", nil) + require.NoError(t, err) + + _, err = client.Do(req) + require.Error(t, err, "expected IMDS connection to be blocked") + assert.Contains(t, err.Error(), "blocked", + "error message should indicate the connection was blocked") +} From 24254128df836a528be8e94438a99026f6c02294 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 10 Jul 2026 15:57:23 +0200 Subject: [PATCH 3/3] test(secrets): close response body in IMDS-blocked test The bodyclose linter flagged client.Do in TestAzureResolver_IMDSBlocked because the *http.Response body was never closed. Capture the response and close its body when non-nil (it is nil on the expected blocked-dial error, but the guard keeps the linter and future non-error paths safe). --- internal/secrets/azure_resolver_httptest_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/secrets/azure_resolver_httptest_test.go b/internal/secrets/azure_resolver_httptest_test.go index 42bfbde17..44c69b442 100644 --- a/internal/secrets/azure_resolver_httptest_test.go +++ b/internal/secrets/azure_resolver_httptest_test.go @@ -300,7 +300,10 @@ func TestAzureResolver_IMDSBlocked(t *testing.T) { "http://169.254.169.254/metadata/instance", nil) require.NoError(t, err) - _, err = client.Do(req) + resp, err := client.Do(req) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } require.Error(t, err, "expected IMDS connection to be blocked") assert.Contains(t, err.Error(), "blocked", "error message should indicate the connection was blocked")