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",