VYPR
High severityNVD Advisory· Published Mar 10, 2026· Updated Apr 14, 2026

Azure MCP Server Tools Elevation of Privilege Vulnerability

CVE-2026-26118

Description

Server-side request forgery (ssrf) in Azure MCP Server allows an authorized attacker to elevate privileges over a network.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

SSRF vulnerability in Azure MCP Server allows an authorized attacker to elevate privileges over a network by bypassing URL validation.

Vulnerability

Description

CVE-2026-26118 is a server-side request forgery (SSRF) vulnerability in Microsoft Azure MCP (Model Context Protocol) Server. The root cause is insufficient validation of URLs provided to the server, particularly in the KustoClient component, which handles connections to Azure Data Explorer (Kusto) clusters. Prior to the fix, the server did not properly restrict which hosts or URIs could be accessed, allowing an attacker to make the server send crafted requests to arbitrary internal or external endpoints [1], [2].

Exploitation and

Attack Surface

An authorized attacker with network access to an Azure MCP Server can exploit this by supplying a maliciously crafted URL that bypasses the intended domain allowlist. The server would then fetch resources from unintended resources, potentially on internal networks or cloud metadata endpoints. The vulnerability does not require prior authentication beyond being an authorized user of the MCP server, and it can be triggered over the network without direct access to the underlying host system [1], [2], [3].

Impact

Successful exploitation could allow the attacker to elevate privileges of the Azure MCP Server to be elevated, enabling the attacker to access sensitive data or services that the server has permission to reach. This includes the potential to retrieve cloud instance metadata, internal service interfaces, or other protected resources that are accessible from the server's network context [3].

Mitigation and

Status

Microsoft has addressed in a commit to the microsoft/mcp repository [2]. The fix was released as part of version 2.0.0-beta.17 of the Azure.Mcp.Server package, which adds a validated allowlist of Kusto endpoint suffixes and hostnames. Users are advised to update to the latest version to mitigate this SSRF risk [2], [4].

AI Insight generated on May 18, 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.

PackageAffected versionsPatched versions
Azure.McpNuGet
>= 2.0.0-beta.1, < 2.0.0-beta.172.0.0-beta.17
Azure.McpNuGet
>= 1.0.0, < 1.0.21.0.2
@azure/mcpnpm
>= 2.0.0-beta.1, < 2.0.0-beta.172.0.0-beta.17
msmcp-azurePyPI
>= 2.0.0b14, < 2.0.0b172.0.0b17
@azure/mcpnpm
>= 1.0.0, < 1.0.21.0.2

Affected products

5
  • Microsoft/Azure MCP Server Tools 1.0.0 (npm)v5
    Range: 1.0.0
  • Microsoft/Azure MCP Server Tools 1.0.0 (NuGet)v5
    Range: 1.0.0
  • Microsoft/Azure MCP Server Tools 2.0.0 (npm)v5
    Range: 2.0.0-beta.1
  • Microsoft/Azure MCP Server Tools 2.0.0 (NuGet)v5
    Range: 2.0.0-beta.1
  • Microsoft/Azure MCP Server Tools 2.0.0 (PyPi)v5
    Range: 2.0.0-beta.1

Patches

1
804ff6029320

add url validations (#1634)

https://github.com/microsoft/mcpXiang YanFeb 3, 2026via ghsa
6 files changed · +546 14
  • servers/Azure.Mcp.Server/changelog-entries/input-validation-improvements.yaml+6 0 added
    @@ -0,0 +1,6 @@
    +changes:
    +  - section: "Bugs Fixed"
    +    description: |
    +      Improved input validation in ResourceHealth and Kusto tools:
    +      - ResourceHealth: Added resource ID validation using Azure.Core.ResourceIdentifier.Parse()
    +      - Kusto: Added cluster URI validation with domain suffix and hostname allowlist
    
  • tools/Azure.Mcp.Tools.Kusto/src/Services/KustoClient.cs+173 1 modified
    @@ -12,7 +12,67 @@ public sealed class KustoClient(
         string userAgent,
         IHttpClientFactory httpClientFactory)
     {
    -    private readonly string _clusterUri = clusterUri;
    +    // Valid Kusto cluster domain suffixes from official Kusto endpoints configuration
    +    private static readonly string[] s_validKustoDomainSuffixes =
    +    [
    +        // Public cloud
    +        ".dxp.aad.azure.com",
    +        ".dxp-dev.aad.azure.com",
    +        ".kusto.azuresynapse.net",
    +        ".kusto.windows.net",
    +        ".kustodev.azuresynapse-dogfood.net",
    +        ".kustodev.windows.net",
    +        ".kustomfa.windows.net",
    +        ".kusto.data.microsoft.com",
    +        ".kusto.fabric.microsoft.com",
    +        ".adx.loganalytics.azure.com",
    +        ".adx.applicationinsights.azure.com",
    +        ".adx.monitor.azure.com",
    +        // US Government
    +        ".kusto.usgovcloudapi.net",
    +        ".kustomfa.usgovcloudapi.net",
    +        ".adx.loganalytics.azure.us",
    +        ".adx.applicationinsights.azure.us",
    +        ".adx.monitor.azure.us",
    +        // China
    +        ".kusto.azuresynapse.azure.cn",
    +        ".kusto.chinacloudapi.cn",
    +        ".kustomfa.chinacloudapi.cn",
    +        ".adx.loganalytics.azure.cn",
    +        ".adx.applicationinsights.azure.cn",
    +        ".adx.monitor.azure.cn",
    +        // Germany
    +        ".kusto.sovcloud-api.de",
    +        ".kustomfa.sovcloud-api.de"
    +    ];
    +
    +    // Exact hostnames that are valid Kusto endpoints
    +    private static readonly HashSet<string> s_validKustoHostnames = new(StringComparer.OrdinalIgnoreCase)
    +    {
    +        // Public cloud
    +        "kusto.aria.microsoft.com",
    +        "eu.kusto.aria.microsoft.com",
    +        "ade.applicationinsights.io",
    +        "ade.loganalytics.io",
    +        "adx.aimon.applicationinsights.azure.com",
    +        "adx.applicationinsights.azure.com",
    +        "adx.loganalytics.azure.com",
    +        "adx.monitor.azure.com",
    +        // US Government
    +        "adx.applicationinsights.azure.us",
    +        "adx.loganalytics.azure.us",
    +        "adx.monitor.azure.us",
    +        // China
    +        "adx.applicationinsights.azure.cn",
    +        "adx.loganalytics.azure.cn",
    +        "adx.monitor.azure.cn",
    +        // Germany
    +        "adx.applicationinsights.azure.de",
    +        "adx.loganalytics.azure.de",
    +        "adx.monitor.azure.de"
    +    };
    +
    +    private readonly string _clusterUri = ValidateAndNormalizeClusterUri(clusterUri);
         private readonly TokenCredential _tokenCredential = tokenCredential;
         private readonly string _userAgent = userAgent;
         private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;
    @@ -21,6 +81,118 @@ public sealed class KustoClient(
         private static readonly string s_clientRequestIdPrefix = "AzMcp";
         private static readonly string s_default_scope = "https://kusto.kusto.windows.net/.default";
     
    +    /// <summary>
    +    /// Validates that the cluster URI is a valid Azure Data Explorer endpoint.
    +    /// Prevents SSRF attacks by rejecting arbitrary URLs.
    +    /// </summary>
    +    private static string ValidateAndNormalizeClusterUri(string clusterUri)
    +    {
    +        ArgumentException.ThrowIfNullOrWhiteSpace(clusterUri, nameof(clusterUri));
    +
    +        // Normalize: remove trailing slash
    +        var normalizedUri = clusterUri.TrimEnd('/');
    +
    +        if (!Uri.TryCreate(normalizedUri, UriKind.Absolute, out var uri))
    +        {
    +            throw new ArgumentException(
    +                $"Invalid Kusto cluster URI format: '{clusterUri}'",
    +                nameof(clusterUri));
    +        }
    +
    +        // Must be HTTPS
    +        if (!uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase))
    +        {
    +            throw new ArgumentException(
    +                "Kusto cluster URI must use HTTPS scheme.",
    +                nameof(clusterUri));
    +        }
    +
    +        // Validate it's a legitimate Kusto cluster domain using simple string matching
    +        if (!IsValidKustoHost(uri.Host))
    +        {
    +            throw new ArgumentException(
    +                $"Invalid Kusto cluster URI. Must be a valid Azure Data Explorer cluster endpoint (e.g., https://clustername.region.kusto.windows.net). Received host: '{uri.Host}'",
    +                nameof(clusterUri));
    +        }
    +
    +        return normalizedUri;
    +    }
    +
    +    /// <summary>
    +    /// Checks if the host is a valid Kusto cluster domain.
    +    /// Uses simple string matching instead of regex to avoid ReDoS attacks.
    +    /// </summary>
    +    private static bool IsValidKustoHost(string host)
    +    {
    +        // Check for exact hostname match first (O(1) lookup with HashSet)
    +        if (s_validKustoHostnames.Contains(host))
    +        {
    +            return true;
    +        }
    +
    +        // Check if host ends with one of the valid Kusto domain suffixes
    +        var matchedSuffix = Array.Find(s_validKustoDomainSuffixes,
    +            suffix => host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase));
    +
    +        if (matchedSuffix == null)
    +        {
    +            return false;
    +        }
    +
    +        // Extract the cluster name part (everything before the suffix)
    +        var clusterPart = host[..^matchedSuffix.Length];
    +
    +        // Cluster part must not be empty
    +        if (string.IsNullOrEmpty(clusterPart))
    +        {
    +            return false;
    +        }
    +
    +        // Validate cluster part contains only valid hostname characters
    +        // Split by dots and validate each segment
    +        var segments = clusterPart.Split('.');
    +
    +        // Validate each segment contains only valid characters
    +        foreach (var segment in segments)
    +        {
    +            if (!IsValidHostnameSegment(segment))
    +            {
    +                return false;
    +            }
    +        }
    +
    +        return true;
    +    }
    +
    +    /// <summary>
    +    /// Validates a hostname segment contains only alphanumeric characters and hyphens,
    +    /// starts with an alphanumeric character, and is not empty.
    +    /// </summary>
    +    private static bool IsValidHostnameSegment(string segment)
    +    {
    +        if (string.IsNullOrEmpty(segment))
    +        {
    +            return false;
    +        }
    +
    +        // Must start with alphanumeric
    +        if (!char.IsLetterOrDigit(segment[0]))
    +        {
    +            return false;
    +        }
    +
    +        // All characters must be alphanumeric or hyphen
    +        foreach (var c in segment)
    +        {
    +            if (!char.IsLetterOrDigit(c) && c != '-')
    +            {
    +                return false;
    +            }
    +        }
    +
    +        return true;
    +    }
    +
         public Task<KustoResult> ExecuteQueryCommandAsync(string database, string text, CancellationToken cancellationToken)
             => ExecuteCommandAsync("/v1/rest/query", database, text, cancellationToken);
     
    
  • tools/Azure.Mcp.Tools.Kusto/tests/Azure.Mcp.Tools.Kusto.LiveTests/assets.json+1 1 modified
    @@ -2,5 +2,5 @@
       "AssetsRepo": "Azure/azure-sdk-assets",
       "AssetsRepoPrefixPath": "",
       "TagPrefix": "Azure.Mcp.Tools.Kusto.LiveTests",
    -  "Tag": "Azure.Mcp.Tools.Kusto.LiveTests_67c068ee26"
    +  "Tag": "Azure.Mcp.Tools.Kusto.LiveTests_31526323b2"
     }
    
  • tools/Azure.Mcp.Tools.Kusto/tests/Azure.Mcp.Tools.Kusto.UnitTests/KustoClientTests.cs+161 0 modified
    @@ -11,6 +11,18 @@ namespace Azure.Mcp.Tools.Kusto.UnitTests;
     
     public sealed class KustoClientTests
     {
    +    private readonly TokenCredential _tokenCredential;
    +    private readonly IHttpClientFactory _httpClientFactory;
    +
    +    public KustoClientTests()
    +    {
    +        _tokenCredential = Substitute.For<TokenCredential>();
    +        _tokenCredential.GetTokenAsync(Arg.Any<TokenRequestContext>(), Arg.Any<CancellationToken>())
    +            .Returns(new ValueTask<AccessToken>(new AccessToken("noop-token", DateTimeOffset.UtcNow.AddHours(1))));
    +
    +        _httpClientFactory = Substitute.For<IHttpClientFactory>();
    +    }
    +
         [Fact]
         public async Task ExecuteCommandAsync_SetsTimeoutTo240Seconds()
         {
    @@ -34,6 +46,155 @@ public async Task ExecuteCommandAsync_SetsTimeoutTo240Seconds()
             Assert.NotNull(result);
         }
     
    +    #region SSRF Protection Tests
    +
    +    [Theory]
    +    [InlineData("https://example.com/v1/rest/query")]
    +    [InlineData("https://external-server.com")]
    +    [InlineData("http://test.kusto.windows.net")] // HTTP instead of HTTPS
    +    [InlineData("https://kusto.windows.net.example.com")]
    +    [InlineData("https://test.kusto.windows.net.example.com/path")]
    +    [InlineData("https://169.254.169.254/metadata/instance")] // Azure IMDS
    +    [InlineData("https://management.azure.com/subscriptions")]
    +    public void Constructor_RejectsInvalidClusterUris(string invalidClusterUri)
    +    {
    +        // Act & Assert
    +        var exception = Assert.Throws<ArgumentException>(
    +            () => new KustoClient(invalidClusterUri, _tokenCredential, "azmcp", _httpClientFactory));
    +
    +        Assert.Contains("Kusto cluster URI", exception.Message);
    +    }
    +
    +    [Theory]
    +    [InlineData("not-a-url")]
    +    [InlineData("ftp://test.kusto.windows.net")]
    +    [InlineData("file:///etc/passwd")]
    +    public void Constructor_RejectsNonHttpsSchemes(string invalidClusterUri)
    +    {
    +        // Act & Assert
    +        Assert.Throws<ArgumentException>(
    +            () => new KustoClient(invalidClusterUri, _tokenCredential, "azmcp", _httpClientFactory));
    +    }
    +
    +    [Theory]
    +    [InlineData(null)]
    +    [InlineData("")]
    +    [InlineData("   ")]
    +    public void Constructor_RejectsNullOrEmptyClusterUri(string? invalidClusterUri)
    +    {
    +        // Act & Assert - ArgumentNullException is thrown for null, ArgumentException for empty/whitespace
    +        Assert.ThrowsAny<ArgumentException>(
    +            () => new KustoClient(invalidClusterUri!, _tokenCredential, "azmcp", _httpClientFactory));
    +    }
    +
    +    [Theory]
    +    [InlineData("https://mycluster.kusto.windows.net")]
    +    [InlineData("https://mycluster.westus.kusto.windows.net")]
    +    [InlineData("https://mycluster.eastus2.kusto.windows.net/")]
    +    [InlineData("https://test-cluster.northeurope.kusto.windows.net")]
    +    // China cloud
    +    [InlineData("https://mycluster.kusto.chinacloudapi.cn")]
    +    [InlineData("https://mycluster.eastus.kusto.chinacloudapi.cn")]
    +    // US Government
    +    [InlineData("https://mycluster.kusto.usgovcloudapi.net")]
    +    [InlineData("https://mycluster.usgovvirginia.kusto.usgovcloudapi.net")]
    +    // Dev
    +    [InlineData("https://mycluster.kustodev.windows.net")]
    +    // Fabric & Synapse
    +    [InlineData("https://mycluster.kusto.fabric.microsoft.com")]
    +    [InlineData("https://mycluster.kusto.azuresynapse.net")]
    +    // Log Analytics / App Insights
    +    [InlineData("https://mycluster.adx.loganalytics.azure.com")]
    +    [InlineData("https://mycluster.adx.applicationinsights.azure.com")]
    +    // Germany
    +    [InlineData("https://mycluster.kusto.sovcloud-api.de")]
    +    public void Constructor_AcceptsValidKustoClusterUris(string validClusterUri)
    +    {
    +        // Act - should not throw
    +        var client = new KustoClient(validClusterUri, _tokenCredential, "azmcp", _httpClientFactory);
    +
    +        // Assert - client was created successfully (no exception thrown)
    +        Assert.NotNull(client);
    +    }
    +
    +    [Theory]
    +    // Public cloud exact hostnames
    +    [InlineData("https://kusto.aria.microsoft.com")]
    +    [InlineData("https://eu.kusto.aria.microsoft.com")]
    +    [InlineData("https://ade.applicationinsights.io")]
    +    [InlineData("https://ade.loganalytics.io")]
    +    [InlineData("https://adx.aimon.applicationinsights.azure.com")]
    +    [InlineData("https://adx.applicationinsights.azure.com")]
    +    [InlineData("https://adx.loganalytics.azure.com")]
    +    [InlineData("https://adx.monitor.azure.com")]
    +    // US Government exact hostnames
    +    [InlineData("https://adx.applicationinsights.azure.us")]
    +    [InlineData("https://adx.loganalytics.azure.us")]
    +    [InlineData("https://adx.monitor.azure.us")]
    +    // China exact hostnames
    +    [InlineData("https://adx.applicationinsights.azure.cn")]
    +    [InlineData("https://adx.loganalytics.azure.cn")]
    +    [InlineData("https://adx.monitor.azure.cn")]
    +    // Germany
    +    [InlineData("https://adx.applicationinsights.azure.de")]
    +    [InlineData("https://adx.loganalytics.azure.de")]
    +    [InlineData("https://adx.monitor.azure.de")]
    +    public void Constructor_AcceptsValidKustoExactHostnames(string validClusterUri)
    +    {
    +        // Act - should not throw
    +        var client = new KustoClient(validClusterUri, _tokenCredential, "azmcp", _httpClientFactory);
    +
    +        // Assert - client was created successfully (no exception thrown)
    +        Assert.NotNull(client);
    +    }
    +
    +    [Theory]
    +    // Single segment cluster names
    +    [InlineData("https://mycluster.kusto.windows.net")]
    +    [InlineData("https://a.kusto.windows.net")] // Single character
    +    [InlineData("https://cluster123.kusto.windows.net")] // With numbers
    +    [InlineData("https://my-cluster.kusto.windows.net")] // With hyphen
    +    [InlineData("https://my-cluster-name.kusto.windows.net")] // Multiple hyphens
    +    // Two segment cluster names (cluster.region)
    +    [InlineData("https://mycluster.westus.kusto.windows.net")]
    +    [InlineData("https://mycluster.eastus2.kusto.windows.net")]
    +    [InlineData("https://my-cluster.north-europe.kusto.windows.net")]
    +    // Three or more segment cluster names
    +    [InlineData("https://mycluster.sub1.sub2.kusto.windows.net")]
    +    [InlineData("https://a.b.c.d.kusto.windows.net")]
    +    [InlineData("https://cluster-1.region-2.zone-3.kusto.windows.net")]
    +    public void Constructor_AcceptsMultiSegmentClusterNames(string validClusterUri)
    +    {
    +        // Act - should not throw
    +        var client = new KustoClient(validClusterUri, _tokenCredential, "azmcp", _httpClientFactory);
    +
    +        // Assert - client was created successfully (no exception thrown)
    +        Assert.NotNull(client);
    +    }
    +
    +    [Theory]
    +    // Empty cluster name (just the suffix)
    +    [InlineData("https://.kusto.windows.net")]
    +    // Segment starting with hyphen
    +    [InlineData("https://-mycluster.kusto.windows.net")]
    +    [InlineData("https://mycluster.-region.kusto.windows.net")]
    +    // Empty segment (double dots)
    +    [InlineData("https://mycluster..kusto.windows.net")]
    +    [InlineData("https://mycluster.region..kusto.windows.net")]
    +    // Segment with invalid characters
    +    [InlineData("https://my_cluster.kusto.windows.net")] // Underscore
    +    [InlineData("https://my!cluster.kusto.windows.net")] // Exclamation
    +    // Segment with only hyphens (must start with alphanumeric)
    +    [InlineData("https://---.kusto.windows.net")]
    +    public void Constructor_RejectsInvalidClusterNameSegments(string invalidClusterUri)
    +    {
    +        // Act & Assert
    +        Assert.Throws<ArgumentException>(
    +            () => new KustoClient(invalidClusterUri, _tokenCredential, "azmcp", _httpClientFactory));
    +    }
    +
    +    #endregion
    +
         private sealed class MockHttpMessageHandler : HttpMessageHandler
         {
             protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    
  • tools/Azure.Mcp.Tools.ResourceHealth/src/Services/ResourceHealthService.cs+22 12 modified
    @@ -28,6 +28,9 @@ public async Task<AvailabilityStatus> GetAvailabilityStatusAsync(
         {
             ValidateRequiredParameters((nameof(resourceId), resourceId));
     
    +        // Parse and validate resource ID format using Azure SDK
    +        var parsedResourceId = ResourceIdentifier.Parse(resourceId);
    +
             try
             {
                 var credential = await GetCredential(cancellationToken);
    @@ -36,12 +39,14 @@ public async Task<AvailabilityStatus> GetAvailabilityStatusAsync(
                     cancellationToken);
     
                 var client = _httpClientFactory.CreateClient();
    -            client.BaseAddress = new Uri(AzureManagementBaseUrl);
                 client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.Token);
     
    -            var url = $"{resourceId}/providers/Microsoft.ResourceHealth/availabilityStatuses/current?api-version={ResourceHealthApiVersion}";
    +            // Construct URL safely using Uri to ensure path is relative to base
    +            var baseUri = new Uri(AzureManagementBaseUrl);
    +            var relativePath = $"{parsedResourceId}/providers/Microsoft.ResourceHealth/availabilityStatuses/current?api-version={ResourceHealthApiVersion}";
    +            var requestUri = new Uri(baseUri, relativePath);
     
    -            using var response = await client.GetAsync(url, cancellationToken);
    +            using var response = await client.GetAsync(requestUri, cancellationToken);
                 response.EnsureSuccessStatusCode();
     
                 var content = await response.Content.ReadAsStringAsync(cancellationToken);
    @@ -84,14 +89,16 @@ public async Task<List<AvailabilityStatus>> ListAvailabilityStatusesAsync(
                     cancellationToken);
     
                 var client = _httpClientFactory.CreateClient();
    -            client.BaseAddress = new Uri(AzureManagementBaseUrl);
                 client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.Token);
     
    -            var url = resourceGroup != null
    +            // Construct URL safely using Uri to ensure path is relative to base
    +            var baseUri = new Uri(AzureManagementBaseUrl);
    +            var relativePath = resourceGroup != null
                     ? $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.ResourceHealth/availabilityStatuses?api-version={ResourceHealthApiVersion}"
                     : $"/subscriptions/{subscriptionId}/providers/Microsoft.ResourceHealth/availabilityStatuses?api-version={ResourceHealthApiVersion}";
    +            var requestUri = new Uri(baseUri, relativePath);
     
    -            using var response = await client.GetAsync(url, cancellationToken);
    +            using var response = await client.GetAsync(requestUri, cancellationToken);
                 response.EnsureSuccessStatusCode();
     
                 var content = await response.Content.ReadAsStringAsync(cancellationToken);
    @@ -139,7 +146,6 @@ public async Task<List<ServiceHealthEvent>> ListServiceHealthEventsAsync(
                     cancellationToken);
     
                 var client = _httpClientFactory.CreateClient();
    -            client.BaseAddress = new Uri(AzureManagementBaseUrl);
                 client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.Token);
     
                 // Build OData filter - using correct property paths for Azure Resource Health API
    @@ -169,27 +175,31 @@ public async Task<List<ServiceHealthEvent>> ListServiceHealthEventsAsync(
                 }
     
                 // Use Service Health Events API with 2025-05-01 version
    -            var url = $"/subscriptions/{subscriptionId}/providers/Microsoft.ResourceHealth/events?api-version=2025-05-01";
    +            var relativePath = $"/subscriptions/{subscriptionId}/providers/Microsoft.ResourceHealth/events?api-version=2025-05-01";
     
                 // Add time range query parameters if provided (not as OData filters)
                 if (!string.IsNullOrWhiteSpace(queryStartTime))
                 {
    -                url += $"&queryStartTime={Uri.EscapeDataString(queryStartTime)}";
    +                relativePath += $"&queryStartTime={Uri.EscapeDataString(queryStartTime)}";
                 }
     
                 if (!string.IsNullOrWhiteSpace(queryEndTime))
                 {
    -                url += $"&queryEndTime={Uri.EscapeDataString(queryEndTime)}";
    +                relativePath += $"&queryEndTime={Uri.EscapeDataString(queryEndTime)}";
                 }
     
                 // Add OData filters if provided
                 if (filterParts.Count > 0)
                 {
                     var combinedFilter = string.Join(" and ", filterParts);
    -                url += $"&$filter={Uri.EscapeDataString(combinedFilter)}";
    +                relativePath += $"&$filter={Uri.EscapeDataString(combinedFilter)}";
                 }
     
    -            using var response = await client.GetAsync(url, cancellationToken);
    +            // Construct URL safely using Uri to ensure path is relative to base
    +            var baseUri = new Uri(AzureManagementBaseUrl);
    +            var requestUri = new Uri(baseUri, relativePath);
    +
    +            using var response = await client.GetAsync(requestUri, cancellationToken);
                 response.EnsureSuccessStatusCode();
                 var content = await response.Content.ReadAsStringAsync(cancellationToken);
     
    
  • tools/Azure.Mcp.Tools.ResourceHealth/tests/Azure.Mcp.Tools.ResourceHealth.UnitTests/Services/ResourceHealthServiceSsrfValidationTests.cs+183 0 added
    @@ -0,0 +1,183 @@
    +// Copyright (c) Microsoft Corporation.
    +// Licensed under the MIT License.
    +
    +using System.Net;
    +using Azure.Core;
    +using Azure.Mcp.Core.Services.Azure.Subscription;
    +using Azure.Mcp.Core.Services.Azure.Tenant;
    +using Azure.Mcp.Tools.ResourceHealth.Services;
    +using NSubstitute;
    +using Xunit;
    +
    +namespace Azure.Mcp.Tools.ResourceHealth.UnitTests.Services;
    +
    +/// <summary>
    +/// Tests to verify resource ID validation in ResourceHealthService.
    +/// These tests ensure that malicious resource IDs containing URLs are rejected.
    +/// Uses Azure.Core.ResourceIdentifier.Parse() for validation.
    +/// </summary>
    +public class ResourceHealthServiceSsrfValidationTests
    +{
    +    private readonly ISubscriptionService _subscriptionService;
    +    private readonly ITenantService _tenantService;
    +    private readonly IHttpClientFactory _httpClientFactory;
    +    private readonly ResourceHealthService _service;
    +
    +    public ResourceHealthServiceSsrfValidationTests()
    +    {
    +        _subscriptionService = Substitute.For<ISubscriptionService>();
    +        _tenantService = Substitute.For<ITenantService>();
    +        _httpClientFactory = Substitute.For<IHttpClientFactory>();
    +        _service = new ResourceHealthService(_subscriptionService, _tenantService, _httpClientFactory);
    +    }
    +
    +    private void SetupMocksForValidRequest(HttpResponseMessage response)
    +    {
    +        // Mock TokenCredential
    +        var mockCredential = Substitute.For<TokenCredential>();
    +        mockCredential.GetTokenAsync(Arg.Any<TokenRequestContext>(), Arg.Any<CancellationToken>())
    +            .Returns(new ValueTask<AccessToken>(new AccessToken("test-token", DateTimeOffset.UtcNow.AddHours(1))));
    +
    +        // Mock TenantService to return the credential
    +        _tenantService.GetTokenCredentialAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
    +            .Returns(Task.FromResult(mockCredential));
    +
    +        // Mock HttpClientFactory
    +        var mockHttpMessageHandler = new MockHttpMessageHandler(response);
    +        var httpClient = new HttpClient(mockHttpMessageHandler);
    +        _httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
    +    }
    +
    +    [Theory]
    +    [InlineData("https://example.com/subscriptions/12345678-1234-1234-1234-123456789012/providers/Microsoft.ResourceHealth/availabilityStatuses/current")]
    +    [InlineData("http://example.com/subscriptions/12345678-1234-1234-1234-123456789012/providers/Microsoft.ResourceHealth/availabilityStatuses/current")]
    +    [InlineData("https://external.com/steal-token")]
    +    [InlineData("http://169.254.169.254/metadata/instance")] // Azure IMDS endpoint
    +    [InlineData("https://management.azure.com.example.com/subscriptions/test")]
    +    public async Task GetAvailabilityStatusAsync_RejectsFullUrls_WithUrlScheme(string maliciousResourceId)
    +    {
    +        // Act & Assert - ResourceIdentifier.Parse() throws FormatException for invalid IDs
    +        await Assert.ThrowsAsync<FormatException>(
    +            () => _service.GetAvailabilityStatusAsync(maliciousResourceId, cancellationToken: TestContext.Current.CancellationToken));
    +    }
    +
    +    [Theory]
    +    [InlineData("subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm")]
    +    [InlineData("resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm")]
    +    [InlineData("providers/Microsoft.Compute/virtualMachines/vm")]
    +    public async Task GetAvailabilityStatusAsync_RejectsResourceIds_NotStartingWithSlash(string invalidResourceId)
    +    {
    +        // Act & Assert - ResourceIdentifier.Parse() throws FormatException for invalid IDs
    +        await Assert.ThrowsAsync<FormatException>(
    +            () => _service.GetAvailabilityStatusAsync(invalidResourceId, cancellationToken: TestContext.Current.CancellationToken));
    +    }
    +
    +    [Theory]
    +    [InlineData("/%68ttps%3A%2F%2Fexample.com")] // URL-encoded https://
    +    [InlineData("/%68ttp%3A%2F%2Fexample.com")]  // URL-encoded http://
    +    [InlineData("/subscriptions/test%3A%2F%2Fexample.com")]
    +    [InlineData("/subscriptions/test%2F%2Fexample.com")]
    +    public async Task GetAvailabilityStatusAsync_RejectsEncodedUrlSchemes(string maliciousResourceId)
    +    {
    +        // Act & Assert - ResourceIdentifier.Parse() throws FormatException for invalid IDs
    +        await Assert.ThrowsAsync<FormatException>(
    +            () => _service.GetAvailabilityStatusAsync(maliciousResourceId, cancellationToken: TestContext.Current.CancellationToken));
    +    }
    +
    +    [Theory]
    +    [InlineData("/subscriptions\\..\\..\\example.com")]
    +    [InlineData("/subscriptions/test\\providers")]
    +    public async Task GetAvailabilityStatusAsync_RejectsBackslashes(string maliciousResourceId)
    +    {
    +        // Act & Assert - ResourceIdentifier.Parse() throws FormatException for invalid IDs
    +        await Assert.ThrowsAsync<FormatException>(
    +            () => _service.GetAvailabilityStatusAsync(maliciousResourceId, cancellationToken: TestContext.Current.CancellationToken));
    +    }
    +
    +    [Theory]
    +    [InlineData("//example.com/path")]
    +    [InlineData("/https://example.com")]
    +    [InlineData("/http://example.com")]
    +    public async Task GetAvailabilityStatusAsync_RejectsEmbeddedUrls(string maliciousResourceId)
    +    {
    +        // Act & Assert - ResourceIdentifier.Parse() throws FormatException for invalid IDs
    +        await Assert.ThrowsAsync<FormatException>(
    +            () => _service.GetAvailabilityStatusAsync(maliciousResourceId, cancellationToken: TestContext.Current.CancellationToken));
    +    }
    +
    +    [Theory]
    +    [InlineData("/random/path/not/azure")]
    +    [InlineData("/subscriptions/not-a-guid/resourceGroups/rg")]
    +    [InlineData("/subscriptions/12345678-1234-1234-1234-12345678901/resourceGroups/rg")] // Invalid GUID (too short)
    +    [InlineData("/subscriptions/12345678-1234-1234-1234-1234567890123/resourceGroups/rg")] // Invalid GUID (too long)
    +    public async Task GetAvailabilityStatusAsync_RejectsInvalidAzureResourceIdFormat(string invalidResourceId)
    +    {
    +        // Act & Assert - ResourceIdentifier.Parse() throws FormatException for invalid IDs
    +        await Assert.ThrowsAsync<FormatException>(
    +            () => _service.GetAvailabilityStatusAsync(invalidResourceId, cancellationToken: TestContext.Current.CancellationToken));
    +    }
    +
    +    [Theory]
    +    [InlineData(null)]
    +    [InlineData("")]
    +    public async Task GetAvailabilityStatusAsync_RejectsNullOrEmptyResourceId(string? invalidResourceId)
    +    {
    +        // Act & Assert - null/empty throws ArgumentException from ValidateRequiredParameters
    +        await Assert.ThrowsAsync<ArgumentException>(
    +            () => _service.GetAvailabilityStatusAsync(invalidResourceId!, cancellationToken: TestContext.Current.CancellationToken));
    +    }
    +
    +    [Fact]
    +    public async Task GetAvailabilityStatusAsync_RejectsWhitespaceResourceId()
    +    {
    +        // Act & Assert - whitespace passes ValidateRequiredParameters but fails ResourceIdentifier.Parse
    +        await Assert.ThrowsAsync<FormatException>(
    +            () => _service.GetAvailabilityStatusAsync("   ", cancellationToken: TestContext.Current.CancellationToken));
    +    }
    +
    +    [Theory]
    +    [InlineData("/subscriptions/12345678-1234-1234-1234-123456789012")]
    +    [InlineData("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/myResourceGroup")]
    +    [InlineData("/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/my-resource-group/providers/Microsoft.Compute/virtualMachines/myVM")]
    +    [InlineData("/subscriptions/ABCDEF12-1234-1234-1234-123456789ABC/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/mystorageaccount")]
    +    [InlineData("/subscriptions/abcdef12-1234-1234-1234-123456789abc/resourceGroups/rg/providers/Microsoft.Web/sites/mywebapp")]
    +    public async Task GetAvailabilityStatusAsync_AcceptsValidAzureResourceIds(string validResourceId)
    +    {
    +        // Arrange - mock all dependencies for a successful request
    +        var mockResponse = new HttpResponseMessage(HttpStatusCode.OK)
    +        {
    +            Content = new StringContent("""
    +                {
    +                    "id": "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/rg/providers/Microsoft.Compute/virtualMachines/vm/providers/Microsoft.ResourceHealth/availabilityStatuses/current",
    +                    "name": "current",
    +                    "type": "Microsoft.ResourceHealth/availabilityStatuses",
    +                    "location": "eastus",
    +                    "properties": {
    +                        "availabilityState": "Available",
    +                        "summary": "Resource is healthy",
    +                        "detailedStatus": "Running normally",
    +                        "reasonType": "",
    +                        "occuredTime": "2025-01-29T00:00:00Z"
    +                    }
    +                }
    +                """, System.Text.Encoding.UTF8, "application/json")
    +        };
    +        SetupMocksForValidRequest(mockResponse);
    +
    +        var result = await _service.GetAvailabilityStatusAsync(validResourceId, cancellationToken: TestContext.Current.CancellationToken);
    +
    +        // Assert - valid resource IDs should pass validation and return a result
    +        Assert.NotNull(result);
    +        Assert.Equal("Available", result.AvailabilityState);
    +    }
    +
    +    private sealed class MockHttpMessageHandler(HttpResponseMessage response) : HttpMessageHandler
    +    {
    +        private readonly HttpResponseMessage _response = response;
    +
    +        protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    +        {
    +            return Task.FromResult(_response);
    +        }
    +    }
    +}
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.