From fb578e1854b4ccc7ec2addbc0218ae75309a0374 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 23 Jul 2026 18:25:23 +0530 Subject: [PATCH 1/6] Add configurable Host and Origin allowlist for the MCP endpoint Introduces runtime.mcp.allowed-hosts so operators can restrict which Host and Origin header values are accepted by the MCP Streamable HTTP endpoint. Loopback hosts are always trusted; a single '*' entry preserves prior behavior. Adds validation middleware, config plumbing, schema, and unit tests. --- schemas/dab.draft.schema.json | 8 + .../McpDnsRebindingProtectionMiddleware.cs | 212 ++++++++++ .../McpRuntimeOptionsConverterFactory.cs | 31 +- src/Config/ObjectModel/McpRuntimeOptions.cs | 27 +- .../McpRuntimeOptionsSerializationTests.cs | 93 +++++ ...cpDnsRebindingProtectionMiddlewareTests.cs | 373 ++++++++++++++++++ src/Service/Startup.cs | 6 + 7 files changed, 748 insertions(+), 2 deletions(-) create mode 100644 src/Azure.DataApiBuilder.Mcp/Core/McpDnsRebindingProtectionMiddleware.cs create mode 100644 src/Service.Tests/Mcp/McpDnsRebindingProtectionMiddlewareTests.cs diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 6aa8f02641..bc79659ecb 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -291,6 +291,14 @@ "type": "string", "description": "Description of the MCP server, exposed as the 'instructions' field in the MCP initialize response to provide behavioral context to MCP clients and agents." }, + "allowed-hosts": { + "type": "array", + "description": "Host names that are trusted to reach the browser-reachable MCP endpoint. Protects the MCP Streamable HTTP transport against DNS rebinding attacks by validating the incoming Host and Origin headers. Loopback hosts (localhost, 127.0.0.1, ::1) are always trusted. A single entry of '*' disables Host/Origin validation and is not recommended.", + "items": { + "type": "string" + }, + "default": [] + }, "dml-tools": { "description": "Configuration for MCP Data Manipulation Language (DML) tools. Set to true/false to enable/disable all tools, or use an object to configure individual tools.", "oneOf": [ diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpDnsRebindingProtectionMiddleware.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpDnsRebindingProtectionMiddleware.cs new file mode 100644 index 0000000000..016f2567f0 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpDnsRebindingProtectionMiddleware.cs @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Configurations; +using Microsoft.AspNetCore.Http; + +namespace Azure.DataApiBuilder.Mcp.Core +{ + /// + /// Middleware that protects the MCP Streamable HTTP transport against DNS rebinding attacks. + /// + /// The MCP endpoint is browser-reachable. A malicious web page can keep its attacker origin + /// while its host name is rebound (via DNS) to a loopback or private DAB address and then send + /// MCP JSON-RPC requests to the configured MCP path. Because DAB holds the backend database + /// connection, such a session could invoke the MCP tool surface using DAB's configured authority. + /// + /// To prevent this, every request targeting the MCP path is validated against a set of trusted + /// host names before it reaches the MCP transport: + /// - The incoming Host header must resolve to a trusted host name. + /// - When present, the Origin header's host must also resolve to a trusted host name. + /// + /// Loopback host names (localhost, 127.0.0.1, ::1) are always trusted so that local MCP clients + /// continue to function. Operators can add additional trusted hosts via the + /// runtime.mcp.allowed-hosts configuration. A single entry of "*" disables the + /// validation for deployments that terminate host validation upstream (not recommended). + /// + public class McpDnsRebindingProtectionMiddleware + { + private readonly RequestDelegate _nextMiddleware; + + /// + /// Wildcard value which, when present in the allowed-hosts list, disables Host/Origin validation. + /// + private const string ALLOW_ALL_HOSTS = "*"; + + /// + /// Loopback host names that are always trusted, allowing local MCP clients to connect. + /// + private static readonly HashSet _loopbackHosts = new(StringComparer.OrdinalIgnoreCase) + { + "localhost", + "127.0.0.1", + "::1" + }; + + public McpDnsRebindingProtectionMiddleware(RequestDelegate next) + { + _nextMiddleware = next; + } + + /// + /// Validates the Host and Origin headers for requests targeting the MCP endpoint before + /// allowing them to proceed through the pipeline. + /// + public async Task InvokeAsync(HttpContext httpContext, RuntimeConfigProvider runtimeConfigProvider) + { + if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) + { + await _nextMiddleware(httpContext); + return; + } + + McpRuntimeOptions mcpOptions = runtimeConfig.Runtime?.Mcp ?? new McpRuntimeOptions(); + + // Only guard requests that are handled by the MCP endpoint. + if (!mcpOptions.Enabled) + { + await _nextMiddleware(httpContext); + return; + } + + string mcpPath = mcpOptions.Path ?? McpRuntimeOptions.DEFAULT_PATH; + if (!httpContext.Request.Path.StartsWithSegments(mcpPath)) + { + await _nextMiddleware(httpContext); + return; + } + + if (!IsRequestFromTrustedHost(httpContext.Request, mcpOptions.AllowedHosts, out string? rejectionReason)) + { + httpContext.Response.StatusCode = StatusCodes.Status403Forbidden; + httpContext.Response.ContentType = "application/json"; + await httpContext.Response.WriteAsync( + "{\"error\":\"Forbidden\",\"message\":\"" + rejectionReason + "\"}"); + return; + } + + await _nextMiddleware(httpContext); + } + + /// + /// Determines whether a request targeting the MCP endpoint originates from a trusted host, + /// validating both the Host and (when present) Origin headers against the trusted host set. + /// + /// The incoming HTTP request. + /// Additional trusted hosts from configuration. + /// A description of why the request was rejected, when applicable. + /// True when the request is allowed; otherwise false. + internal static bool IsRequestFromTrustedHost( + HttpRequest request, + IReadOnlyList? configuredAllowedHosts, + out string? rejectionReason) + { + rejectionReason = null; + + HashSet trustedHosts = BuildTrustedHostSet(configuredAllowedHosts, out bool allowAllHosts); + if (allowAllHosts) + { + return true; + } + + // Validate the Host header. A browser always sends a Host header, and a DNS rebinding + // request carries the attacker-controlled host name rather than a trusted host. + string hostHeaderValue = NormalizeHost(request.Host.Host); + if (string.IsNullOrEmpty(hostHeaderValue) || !trustedHosts.Contains(hostHeaderValue)) + { + rejectionReason = + "The request Host header is not in the list of trusted hosts allowed to reach the MCP endpoint. " + + "Configure runtime.mcp.allowed-hosts to permit non-loopback hosts."; + return false; + } + + // Validate the Origin header when present (browser-initiated cross-origin requests). + if (request.Headers.TryGetValue("Origin", out Microsoft.Extensions.Primitives.StringValues originValues)) + { + string? originValue = originValues.ToString(); + if (!string.IsNullOrEmpty(originValue)) + { + if (!TryGetOriginHost(originValue, out string originHost) + || !trustedHosts.Contains(originHost)) + { + rejectionReason = + "The request Origin header is not in the list of trusted hosts allowed to reach the MCP endpoint. " + + "Configure runtime.mcp.allowed-hosts to permit non-loopback origins."; + return false; + } + } + } + + return true; + } + + /// + /// Builds the set of trusted host names, always including loopback hosts and any + /// configured allowed hosts. Detects the wildcard opt-out value. + /// + private static HashSet BuildTrustedHostSet(IReadOnlyList? configuredAllowedHosts, out bool allowAllHosts) + { + allowAllHosts = false; + HashSet trustedHosts = new(_loopbackHosts, StringComparer.OrdinalIgnoreCase); + + if (configuredAllowedHosts is not null) + { + foreach (string configuredHost in configuredAllowedHosts) + { + if (string.IsNullOrWhiteSpace(configuredHost)) + { + continue; + } + + if (string.Equals(configuredHost.Trim(), ALLOW_ALL_HOSTS, StringComparison.Ordinal)) + { + allowAllHosts = true; + continue; + } + + trustedHosts.Add(NormalizeHost(configuredHost)); + } + } + + return trustedHosts; + } + + /// + /// Extracts the host component from an Origin header value. + /// + private static bool TryGetOriginHost(string originValue, out string originHost) + { + originHost = string.Empty; + + // The literal string "null" is sent for opaque origins (e.g., sandboxed iframes, + // file:// pages). Treat it as untrusted. + if (string.Equals(originValue.Trim(), "null", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (Uri.TryCreate(originValue, UriKind.Absolute, out Uri? originUri)) + { + originHost = NormalizeHost(originUri.Host); + return !string.IsNullOrEmpty(originHost); + } + + return false; + } + + /// + /// Normalizes a host name for comparison by trimming surrounding whitespace and IPv6 + /// bracket delimiters. Comparisons are performed case-insensitively. + /// + private static string NormalizeHost(string host) + { + if (string.IsNullOrWhiteSpace(host)) + { + return string.Empty; + } + + return host.Trim().Trim('[', ']'); + } + } +} diff --git a/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs b/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs index 8b3c640725..e4af65707b 100644 --- a/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs +++ b/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs @@ -66,12 +66,13 @@ internal McpRuntimeOptionsConverter(DeserializationVariableReplacementSettings? string? path = null; DmlToolsConfig? dmlTools = null; string? description = null; + List? allowedHosts = null; while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) { - return new McpRuntimeOptions(enabled, path, dmlTools, description); + return new McpRuntimeOptions(enabled, path, dmlTools, description, allowedHosts); } string? propertyName = reader.GetString(); @@ -107,6 +108,27 @@ internal McpRuntimeOptionsConverter(DeserializationVariableReplacementSettings? break; + case "allowed-hosts": + if (reader.TokenType is not JsonTokenType.Null) + { + if (reader.TokenType is not JsonTokenType.StartArray) + { + throw new JsonException("The mcp.allowed-hosts property must be an array of strings."); + } + + allowedHosts = new List(); + while (reader.Read() && reader.TokenType is not JsonTokenType.EndArray) + { + string? host = reader.DeserializeString(_replacementSettings); + if (!string.IsNullOrWhiteSpace(host)) + { + allowedHosts.Add(host); + } + } + } + + break; + default: throw new JsonException($"Unexpected property {propertyName}"); } @@ -150,6 +172,13 @@ public override void Write(Utf8JsonWriter writer, McpRuntimeOptions value, JsonS JsonSerializer.Serialize(writer, value.Description, options); } + // Write allowed-hosts only when the user explicitly provided them. + if (value?.UserProvidedAllowedHosts is true && value.AllowedHosts is not null) + { + writer.WritePropertyName("allowed-hosts"); + JsonSerializer.Serialize(writer, value.AllowedHosts, options); + } + writer.WriteEndObject(); } } diff --git a/src/Config/ObjectModel/McpRuntimeOptions.cs b/src/Config/ObjectModel/McpRuntimeOptions.cs index e17d53fc8f..cd27b7b832 100644 --- a/src/Config/ObjectModel/McpRuntimeOptions.cs +++ b/src/Config/ObjectModel/McpRuntimeOptions.cs @@ -36,12 +36,23 @@ public record McpRuntimeOptions [JsonPropertyName("description")] public string? Description { get; init; } + /// + /// The set of host names that are trusted to reach the MCP endpoint. + /// Used to protect the browser-reachable MCP Streamable HTTP transport against + /// DNS rebinding attacks by validating the incoming Host and Origin headers. + /// Loopback host names (localhost, 127.0.0.1, ::1) are always trusted. + /// A single entry of "*" disables Host/Origin validation (not recommended). + /// + [JsonPropertyName("allowed-hosts")] + public List? AllowedHosts { get; init; } + [JsonConstructor] public McpRuntimeOptions( bool? Enabled = null, string? Path = null, DmlToolsConfig? DmlTools = null, - string? Description = null) + string? Description = null, + List? AllowedHosts = null) { this.Enabled = Enabled ?? true; @@ -67,6 +78,12 @@ public McpRuntimeOptions( } this.Description = Description; + + if (AllowedHosts is not null) + { + this.AllowedHosts = AllowedHosts; + UserProvidedAllowedHosts = true; + } } /// @@ -78,4 +95,12 @@ public McpRuntimeOptions( [JsonIgnore(Condition = JsonIgnoreCondition.Always)] [MemberNotNullWhen(true, nameof(Enabled))] public bool UserProvidedPath { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write the allowed-hosts + /// property and value to the runtime config file. When the user doesn't provide + /// the property, DAB should not write it to a serialized config. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool UserProvidedAllowedHosts { get; init; } = false; } diff --git a/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs index f706048107..a1e493e87a 100644 --- a/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs +++ b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs @@ -279,6 +279,99 @@ public void TestIsMcpEnabledReturnsFalseWhenExplicitlyDisabled() Assert.IsFalse(config.IsMcpEnabled, "IsMcpEnabled should be false when explicitly disabled"); } + /// + /// Validates that the mcp.allowed-hosts array is round-tripped through serialization + /// and deserialization when the user explicitly provides it. + /// + [TestMethod] + public void TestMcpAllowedHostsSerializationRoundTrip() + { + // Arrange + McpRuntimeOptions mcpOptions = new( + Enabled: true, + Path: "/mcp", + DmlTools: null, + Description: null, + AllowedHosts: new List { "api.contoso.com", "dab.internal" } + ); + + RuntimeConfig config = CreateMinimalConfigWithMcp(mcpOptions); + + // Act + string json = config.ToJson(); + bool parseSuccess = RuntimeConfigLoader.TryParseConfig(json, out RuntimeConfig deserializedConfig); + + // Assert + Assert.IsTrue(parseSuccess, "Failed to deserialize config with mcp allowed-hosts"); + Assert.IsTrue(json.Contains("\"allowed-hosts\""), "JSON should contain allowed-hosts field"); + Assert.IsNotNull(deserializedConfig.Runtime?.Mcp?.AllowedHosts, "AllowedHosts should not be null"); + CollectionAssert.AreEqual( + new List { "api.contoso.com", "dab.internal" }, + deserializedConfig.Runtime.Mcp.AllowedHosts, + "AllowedHosts should round-trip exactly"); + } + + /// + /// Validates that the mcp.allowed-hosts field is omitted from serialized JSON + /// when the user does not provide it, preserving backward compatibility. + /// + [TestMethod] + public void TestMcpAllowedHostsOmittedWhenNotProvided() + { + // Arrange + McpRuntimeOptions mcpOptions = new( + Enabled: true, + Path: "/mcp", + DmlTools: null, + Description: null, + AllowedHosts: null + ); + + RuntimeConfig config = CreateMinimalConfigWithMcp(mcpOptions); + + // Act + string json = config.ToJson(); + bool parseSuccess = RuntimeConfigLoader.TryParseConfig(json, out RuntimeConfig deserializedConfig); + + // Assert + Assert.IsTrue(parseSuccess, "Failed to deserialize config without allowed-hosts"); + Assert.IsFalse(json.Contains("\"allowed-hosts\""), "JSON should not contain allowed-hosts field when not provided"); + Assert.IsNull(deserializedConfig.Runtime?.Mcp?.AllowedHosts, "AllowedHosts should be null when not provided"); + } + + /// + /// Validates that an existing config JSON containing mcp.allowed-hosts is deserialized correctly. + /// + [TestMethod] + public void TestMcpAllowedHostsDeserializationFromJson() + { + // Arrange + string configJson = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""Server=test;Database=test;"" + }, + ""runtime"": { + ""mcp"": { + ""enabled"": true, + ""allowed-hosts"": [ ""api.contoso.com"", ""*.example.com"" ] + } + }, + ""entities"": {} + }"; + + // Act + bool parseSuccess = RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig config); + + // Assert + Assert.IsTrue(parseSuccess, "Failed to deserialize config with allowed-hosts"); + Assert.IsNotNull(config.Runtime?.Mcp?.AllowedHosts, "AllowedHosts should not be null"); + Assert.AreEqual(2, config.Runtime.Mcp.AllowedHosts.Count, "AllowedHosts should have two entries"); + Assert.AreEqual("api.contoso.com", config.Runtime.Mcp.AllowedHosts[0]); + Assert.AreEqual("*.example.com", config.Runtime.Mcp.AllowedHosts[1]); + } + /// /// Creates a minimal RuntimeConfig with the specified MCP options for testing. /// diff --git a/src/Service.Tests/Mcp/McpDnsRebindingProtectionMiddlewareTests.cs b/src/Service.Tests/Mcp/McpDnsRebindingProtectionMiddlewareTests.cs new file mode 100644 index 0000000000..3b1e8c7ec4 --- /dev/null +++ b/src/Service.Tests/Mcp/McpDnsRebindingProtectionMiddlewareTests.cs @@ -0,0 +1,373 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.IO.Abstractions.TestingHelpers; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Mcp.Core; +using Microsoft.AspNetCore.Http; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Unit tests for which protects the + /// browser-reachable MCP Streamable HTTP transport against DNS rebinding attacks by + /// validating the Host and Origin headers before the request reaches the MCP endpoint. + /// + [TestClass] + public class McpDnsRebindingProtectionMiddlewareTests + { + private const string CUSTOM_CONFIG = "mcp-dns-rebinding-config.json"; + private const string MCP_PATH = "/mcp"; + + #region IsRequestFromTrustedHost (pure logic) + + /// + /// Loopback hosts are always trusted so that local MCP clients continue to work + /// without additional configuration. + /// + [DataTestMethod] + [DataRow("localhost", DisplayName = "localhost host")] + [DataRow("127.0.0.1", DisplayName = "IPv4 loopback host")] + [DataRow("::1", DisplayName = "IPv6 loopback host")] + public void IsRequestFromTrustedHost_LoopbackHost_IsAllowed(string host) + { + HttpRequest request = BuildRequest(host: host); + + bool allowed = McpDnsRebindingProtectionMiddleware.IsRequestFromTrustedHost( + request, configuredAllowedHosts: null, out string reason); + + Assert.IsTrue(allowed, $"Loopback host '{host}' should be trusted. Reason: {reason}"); + Assert.IsNull(reason); + } + + /// + /// A rebound request carries the attacker's host name in the Host header, which is not + /// a trusted host and must be rejected. This is the core DNS rebinding defense. + /// + [TestMethod] + public void IsRequestFromTrustedHost_AttackerHost_IsRejected() + { + HttpRequest request = BuildRequest(host: "attacker.com"); + + bool allowed = McpDnsRebindingProtectionMiddleware.IsRequestFromTrustedHost( + request, configuredAllowedHosts: null, out string reason); + + Assert.IsFalse(allowed, "Untrusted attacker host should be rejected."); + StringAssert.Contains(reason, "Host header"); + } + + /// + /// The port component of the Host header is ignored; only the host name is validated. + /// + [TestMethod] + public void IsRequestFromTrustedHost_LoopbackWithPort_IsAllowed() + { + HttpRequest request = BuildRequest(host: "localhost", port: 8087); + + bool allowed = McpDnsRebindingProtectionMiddleware.IsRequestFromTrustedHost( + request, configuredAllowedHosts: null, out string reason); + + Assert.IsTrue(allowed, $"Loopback host with a port should be trusted. Reason: {reason}"); + } + + /// + /// A configured allowed host is trusted for non-loopback deployments. + /// + [TestMethod] + public void IsRequestFromTrustedHost_ConfiguredHost_IsAllowed() + { + HttpRequest request = BuildRequest(host: "dab.internal"); + + bool allowed = McpDnsRebindingProtectionMiddleware.IsRequestFromTrustedHost( + request, configuredAllowedHosts: new List { "dab.internal" }, out string reason); + + Assert.IsTrue(allowed, $"Configured host should be trusted. Reason: {reason}"); + } + + /// + /// Host name comparison is case-insensitive. + /// + [TestMethod] + public void IsRequestFromTrustedHost_ConfiguredHostDifferentCase_IsAllowed() + { + HttpRequest request = BuildRequest(host: "DAB.Internal"); + + bool allowed = McpDnsRebindingProtectionMiddleware.IsRequestFromTrustedHost( + request, configuredAllowedHosts: new List { "dab.internal" }, out _); + + Assert.IsTrue(allowed, "Host comparison should be case-insensitive."); + } + + /// + /// The wildcard entry disables Host/Origin validation for deployments that terminate + /// host validation upstream. + /// + [TestMethod] + public void IsRequestFromTrustedHost_WildcardAllowedHost_AllowsAnyHost() + { + HttpRequest request = BuildRequest(host: "attacker.com", origin: "http://attacker.com"); + + bool allowed = McpDnsRebindingProtectionMiddleware.IsRequestFromTrustedHost( + request, configuredAllowedHosts: new List { "*" }, out string reason); + + Assert.IsTrue(allowed, "Wildcard should disable Host/Origin validation."); + Assert.IsNull(reason); + } + + /// + /// When a request has a trusted Host header but an untrusted Origin header (the classic + /// DNS rebinding cross-origin scenario), the request is rejected. + /// + [TestMethod] + public void IsRequestFromTrustedHost_TrustedHostUntrustedOrigin_IsRejected() + { + HttpRequest request = BuildRequest(host: "localhost", origin: "http://attacker.com"); + + bool allowed = McpDnsRebindingProtectionMiddleware.IsRequestFromTrustedHost( + request, configuredAllowedHosts: null, out string reason); + + Assert.IsFalse(allowed, "Untrusted Origin should be rejected even with a trusted Host."); + StringAssert.Contains(reason, "Origin header"); + } + + /// + /// A trusted Host together with a trusted (loopback) Origin is allowed. + /// + [TestMethod] + public void IsRequestFromTrustedHost_TrustedHostTrustedOrigin_IsAllowed() + { + HttpRequest request = BuildRequest(host: "localhost", port: 5000, origin: "http://localhost:5000"); + + bool allowed = McpDnsRebindingProtectionMiddleware.IsRequestFromTrustedHost( + request, configuredAllowedHosts: null, out string reason); + + Assert.IsTrue(allowed, $"Trusted Host and Origin should be allowed. Reason: {reason}"); + } + + /// + /// An opaque Origin (the literal string "null" sent by sandboxed iframes and file:// pages) + /// is treated as untrusted. + /// + [TestMethod] + public void IsRequestFromTrustedHost_NullOrigin_IsRejected() + { + HttpRequest request = BuildRequest(host: "localhost", origin: "null"); + + bool allowed = McpDnsRebindingProtectionMiddleware.IsRequestFromTrustedHost( + request, configuredAllowedHosts: null, out string reason); + + Assert.IsFalse(allowed, "Opaque 'null' Origin should be rejected."); + StringAssert.Contains(reason, "Origin header"); + } + + /// + /// Requests without an Origin header (non-browser MCP clients) are validated on the Host + /// header alone. + /// + [TestMethod] + public void IsRequestFromTrustedHost_NoOriginHeaderTrustedHost_IsAllowed() + { + HttpRequest request = BuildRequest(host: "127.0.0.1"); + + bool allowed = McpDnsRebindingProtectionMiddleware.IsRequestFromTrustedHost( + request, configuredAllowedHosts: null, out _); + + Assert.IsTrue(allowed, "A request with a trusted Host and no Origin should be allowed."); + } + + /// + /// An empty Host header is rejected. + /// + [TestMethod] + public void IsRequestFromTrustedHost_EmptyHost_IsRejected() + { + HttpRequest request = new DefaultHttpContext().Request; + + bool allowed = McpDnsRebindingProtectionMiddleware.IsRequestFromTrustedHost( + request, configuredAllowedHosts: null, out string reason); + + Assert.IsFalse(allowed, "An empty Host header should be rejected."); + StringAssert.Contains(reason, "Host header"); + } + + #endregion + + #region InvokeAsync (pipeline behavior) + + /// + /// Requests that do not target the MCP path bypass the DNS rebinding validation. + /// + [TestMethod] + public async Task InvokeAsync_NonMcpPath_CallsNext() + { + RuntimeConfigProvider provider = BuildProvider(BuildConfig()); + (McpDnsRebindingProtectionMiddleware middleware, NextTracker next) = BuildMiddleware(); + + HttpContext context = BuildContext(path: "/api/Book", host: "attacker.com"); + await middleware.InvokeAsync(context, provider); + + Assert.IsTrue(next.WasCalled, "Non-MCP path requests should pass through to the next middleware."); + Assert.AreEqual(StatusCodes.Status200OK, context.Response.StatusCode); + } + + /// + /// When MCP is disabled, no validation is applied even for the MCP path. + /// + [TestMethod] + public async Task InvokeAsync_McpDisabled_CallsNext() + { + RuntimeConfigProvider provider = BuildProvider(BuildConfig(mcpEnabled: false)); + (McpDnsRebindingProtectionMiddleware middleware, NextTracker next) = BuildMiddleware(); + + HttpContext context = BuildContext(path: MCP_PATH, host: "attacker.com"); + await middleware.InvokeAsync(context, provider); + + Assert.IsTrue(next.WasCalled, "When MCP is disabled the request should pass through."); + } + + /// + /// A DNS rebinding request to the MCP path with an untrusted Host is rejected with 403 + /// and does not reach the MCP endpoint. + /// + [TestMethod] + public async Task InvokeAsync_McpPathUntrustedHost_ReturnsForbidden() + { + RuntimeConfigProvider provider = BuildProvider(BuildConfig()); + (McpDnsRebindingProtectionMiddleware middleware, NextTracker next) = BuildMiddleware(); + + HttpContext context = BuildContext(path: MCP_PATH, host: "attacker.com", origin: "http://attacker.com"); + await middleware.InvokeAsync(context, provider); + + Assert.IsFalse(next.WasCalled, "The MCP endpoint must not be reached for an untrusted host."); + Assert.AreEqual(StatusCodes.Status403Forbidden, context.Response.StatusCode); + } + + /// + /// A request to the MCP path from a trusted loopback host is allowed to proceed. + /// + [TestMethod] + public async Task InvokeAsync_McpPathLoopbackHost_CallsNext() + { + RuntimeConfigProvider provider = BuildProvider(BuildConfig()); + (McpDnsRebindingProtectionMiddleware middleware, NextTracker next) = BuildMiddleware(); + + HttpContext context = BuildContext(path: MCP_PATH, host: "localhost", port: 5000); + await middleware.InvokeAsync(context, provider); + + Assert.IsTrue(next.WasCalled, "A trusted loopback MCP request should pass through."); + } + + /// + /// A request to the MCP path from a configured trusted host is allowed to proceed. + /// + [TestMethod] + public async Task InvokeAsync_McpPathConfiguredHost_CallsNext() + { + RuntimeConfigProvider provider = BuildProvider( + BuildConfig(allowedHosts: new List { "dab.contoso.com" })); + (McpDnsRebindingProtectionMiddleware middleware, NextTracker next) = BuildMiddleware(); + + HttpContext context = BuildContext(path: MCP_PATH, host: "dab.contoso.com"); + await middleware.InvokeAsync(context, provider); + + Assert.IsTrue(next.WasCalled, "A configured trusted host MCP request should pass through."); + } + + #endregion + + #region Helpers + + private static HttpRequest BuildRequest(string host, int? port = null, string origin = null) + { + DefaultHttpContext context = new(); + context.Request.Host = port is null ? new HostString(host) : new HostString(host, port.Value); + if (origin is not null) + { + context.Request.Headers["Origin"] = origin; + } + + return context.Request; + } + + private static HttpContext BuildContext(string path, string host, int? port = null, string origin = null) + { + DefaultHttpContext context = new(); + context.Request.Path = path; + context.Request.Host = port is null ? new HostString(host) : new HostString(host, port.Value); + if (origin is not null) + { + context.Request.Headers["Origin"] = origin; + } + + return context; + } + + private static (McpDnsRebindingProtectionMiddleware, NextTracker) BuildMiddleware() + { + NextTracker tracker = new(); + McpDnsRebindingProtectionMiddleware middleware = new(tracker.InvokeAsync); + return (middleware, tracker); + } + + private static RuntimeConfig BuildConfig( + bool mcpEnabled = true, + List allowedHosts = null) + { + DataSource dataSource = new( + DatabaseType: DatabaseType.MSSQL, + ConnectionString: "Server=test;Database=test;", + Options: null); + + McpRuntimeOptions mcpOptions = new( + Enabled: mcpEnabled, + Path: MCP_PATH, + DmlTools: null, + Description: null, + AllowedHosts: allowedHosts); + + RuntimeOptions runtimeOptions = new( + Rest: null, + GraphQL: null, + Host: null, + Mcp: mcpOptions); + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: dataSource, + Runtime: runtimeOptions, + Entities: new RuntimeEntities(new Dictionary())); + } + + private static RuntimeConfigProvider BuildProvider(RuntimeConfig config) + { + MockFileSystem fileSystem = new(); + fileSystem.AddFile(CUSTOM_CONFIG, new MockFileData(config.ToJson())); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + loader.UpdateConfigFilePath(CUSTOM_CONFIG); + return new RuntimeConfigProvider(loader); + } + + /// + /// Test double for the next in the pipeline that records + /// whether it was invoked. + /// + private sealed class NextTracker + { + public bool WasCalled { get; private set; } + + public Task InvokeAsync(HttpContext context) + { + _ = context; + WasCalled = true; + return Task.CompletedTask; + } + } + + #endregion + } +} diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index bcbaa235f8..b41550bf2e 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -999,6 +999,12 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC // without proper authorization headers. app.UseClientRoleHeaderAuthorizationMiddleware(); + // Protect the browser-reachable MCP Streamable HTTP transport against DNS rebinding + // attacks by validating the Host and Origin headers before the request reaches the + // MCP endpoint. Loopback hosts are always trusted; additional trusted hosts can be + // configured via runtime.mcp.allowed-hosts. + app.UseMiddleware(); + IRequestExecutorManager requestExecutorManager = app.ApplicationServices.GetRequiredService(); _hotReloadEventHandler.Subscribe( "GRAPHQL_SCHEMA_EVICTION_ON_CONFIG_CHANGED", From 0fd59c25e231eed1ace517e6c1d08dce0eaf7350 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Fri, 24 Jul 2026 16:37:41 +0530 Subject: [PATCH 2/6] Exclude UserProvidedAllowedHosts from Verify snapshots The new UserProvidedAllowedHosts helper flag is not serialized to the config file, mirroring UserProvidedPath. Ignore it in the Cli.Tests and Service.Tests Verify module initializers so snapshot tests remain stable. --- src/Cli.Tests/ModuleInitializer.cs | 2 ++ src/Service.Tests/ModuleInitializer.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Cli.Tests/ModuleInitializer.cs b/src/Cli.Tests/ModuleInitializer.cs index 4f4584a535..d514fdbb9b 100644 --- a/src/Cli.Tests/ModuleInitializer.cs +++ b/src/Cli.Tests/ModuleInitializer.cs @@ -137,6 +137,8 @@ public static void Init() VerifierSettings.IgnoreMember(options => options.EnableLegacyDateTimeScalar); // Ignore UserProvidedPath as that's not serialized in our config file. VerifierSettings.IgnoreMember(options => options.UserProvidedPath); + // Ignore UserProvidedAllowedHosts as that's not serialized in our config file. + VerifierSettings.IgnoreMember(options => options.UserProvidedAllowedHosts); // Customise the path where we store snapshots, so they are easier to locate in a PR review. VerifyBase.DerivePathInfo( (sourceFile, projectDirectory, type, method) => new( diff --git a/src/Service.Tests/ModuleInitializer.cs b/src/Service.Tests/ModuleInitializer.cs index 4bc79aa403..c3dc008c7b 100644 --- a/src/Service.Tests/ModuleInitializer.cs +++ b/src/Service.Tests/ModuleInitializer.cs @@ -137,6 +137,8 @@ public static void Init() VerifierSettings.IgnoreMember(options => options.EnableLegacyDateTimeScalar); // Ignore UserProvidedPath as that's not serialized in our config file. VerifierSettings.IgnoreMember(options => options.UserProvidedPath); + // Ignore UserProvidedAllowedHosts as that's not serialized in our config file. + VerifierSettings.IgnoreMember(options => options.UserProvidedAllowedHosts); // Customise the path where we store snapshots, so they are easier to locate in a PR review. VerifyBase.DerivePathInfo( (sourceFile, projectDirectory, type, method) => new( From 53c72e13dd5051d7e4dcf8fd272c82cc5974e03c Mon Sep 17 00:00:00 2001 From: Souvik Ghosh Date: Fri, 24 Jul 2026 17:39:04 +0530 Subject: [PATCH 3/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../McpRuntimeOptionsConverterFactory.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs b/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs index e4af65707b..8fa385c388 100644 --- a/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs +++ b/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs @@ -119,11 +119,20 @@ internal McpRuntimeOptionsConverter(DeserializationVariableReplacementSettings? allowedHosts = new List(); while (reader.Read() && reader.TokenType is not JsonTokenType.EndArray) { - string? host = reader.DeserializeString(_replacementSettings); - if (!string.IsNullOrWhiteSpace(host)) - { - allowedHosts.Add(host); - } +string? host = reader.DeserializeString(_replacementSettings)?.Trim(); +if (string.IsNullOrEmpty(host)) +{ + continue; +} + +string normalizedHost = host.Trim('[', ']'); +if (!string.Equals(host, "*", StringComparison.Ordinal) && + Uri.CheckHostName(normalizedHost) == UriHostNameType.Unknown) +{ + throw new JsonException("Each entry in mcp.allowed-hosts must be a host name (or \"*\")."); +} + +allowedHosts.Add(host); } } From 48246e0c034d2648defa9cdcfa20ad7caad735ff Mon Sep 17 00:00:00 2001 From: Souvik Ghosh Date: Fri, 24 Jul 2026 17:39:25 +0530 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Configuration/McpRuntimeOptionsSerializationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs index a1e493e87a..f877840c8b 100644 --- a/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs +++ b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs @@ -355,7 +355,7 @@ public void TestMcpAllowedHostsDeserializationFromJson() ""runtime"": { ""mcp"": { ""enabled"": true, - ""allowed-hosts"": [ ""api.contoso.com"", ""*.example.com"" ] + ""allowed-hosts"": [ ""api.contoso.com"", ""sub.example.com"" ] } }, ""entities"": {} From 2ca31318f70172438c2db6f83f00a459da67613a Mon Sep 17 00:00:00 2001 From: Souvik Ghosh Date: Fri, 24 Jul 2026 17:39:34 +0530 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Configuration/McpRuntimeOptionsSerializationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs index f877840c8b..6fef30f474 100644 --- a/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs +++ b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs @@ -369,7 +369,7 @@ public void TestMcpAllowedHostsDeserializationFromJson() Assert.IsNotNull(config.Runtime?.Mcp?.AllowedHosts, "AllowedHosts should not be null"); Assert.AreEqual(2, config.Runtime.Mcp.AllowedHosts.Count, "AllowedHosts should have two entries"); Assert.AreEqual("api.contoso.com", config.Runtime.Mcp.AllowedHosts[0]); - Assert.AreEqual("*.example.com", config.Runtime.Mcp.AllowedHosts[1]); + Assert.AreEqual("sub.example.com", config.Runtime.Mcp.AllowedHosts[1]); } /// From a84136c79bd0c197e8f569991a2fc22a280c95c5 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Mon, 27 Jul 2026 11:32:16 +0530 Subject: [PATCH 6/6] format whitespace --- src/Config/Converters/McpRuntimeOptionsConverterFactory.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs b/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs index e4af65707b..99ab3ff90d 100644 --- a/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs +++ b/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs @@ -120,6 +120,7 @@ internal McpRuntimeOptionsConverter(DeserializationVariableReplacementSettings? while (reader.Read() && reader.TokenType is not JsonTokenType.EndArray) { string? host = reader.DeserializeString(_replacementSettings); + if (!string.IsNullOrWhiteSpace(host)) { allowedHosts.Add(host);