Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
66 changes: 62 additions & 4 deletions internal/secrets/azure_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,70 @@ 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/keyvault/azsecrets"
"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
vaultURL string
}

// 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,
Expand All @@ -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)
}
Expand Down Expand Up @@ -88,7 +146,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)
Expand Down
8 changes: 4 additions & 4 deletions internal/secrets/azure_resolver_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
},
Expand Down Expand Up @@ -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())
Expand All @@ -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
}

Expand Down
31 changes: 27 additions & 4 deletions internal/secrets/azure_resolver_httptest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -285,3 +288,23 @@ 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)

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")
}
Loading
Loading