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/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/Config/Converters/McpRuntimeOptionsConverterFactory.cs b/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs
index 8b3c640725..9b84ff0da3 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,36 @@ 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)?.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);
+ }
+ }
+
+ break;
+
default:
throw new JsonException($"Unexpected property {propertyName}");
}
@@ -150,6 +181,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..6fef30f474 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"", ""sub.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("sub.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.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(
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",