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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions schemas/dab.draft.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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 <c>Host</c> header must resolve to a trusted host name.
/// - When present, the <c>Origin</c> 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
/// <c>runtime.mcp.allowed-hosts</c> configuration. A single entry of <c>"*"</c> disables the
/// validation for deployments that terminate host validation upstream (not recommended).
/// </summary>
public class McpDnsRebindingProtectionMiddleware
{
private readonly RequestDelegate _nextMiddleware;

/// <summary>
/// Wildcard value which, when present in the allowed-hosts list, disables Host/Origin validation.
/// </summary>
private const string ALLOW_ALL_HOSTS = "*";

/// <summary>
/// Loopback host names that are always trusted, allowing local MCP clients to connect.
/// </summary>
private static readonly HashSet<string> _loopbackHosts = new(StringComparer.OrdinalIgnoreCase)
{
"localhost",
"127.0.0.1",
"::1"
};

public McpDnsRebindingProtectionMiddleware(RequestDelegate next)
{
_nextMiddleware = next;
}

/// <summary>
/// Validates the Host and Origin headers for requests targeting the MCP endpoint before
/// allowing them to proceed through the pipeline.
/// </summary>
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);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="request">The incoming HTTP request.</param>
/// <param name="configuredAllowedHosts">Additional trusted hosts from configuration.</param>
/// <param name="rejectionReason">A description of why the request was rejected, when applicable.</param>
/// <returns>True when the request is allowed; otherwise false.</returns>
internal static bool IsRequestFromTrustedHost(
HttpRequest request,
IReadOnlyList<string>? configuredAllowedHosts,
out string? rejectionReason)
{
rejectionReason = null;

HashSet<string> 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;
}

/// <summary>
/// Builds the set of trusted host names, always including loopback hosts and any
/// configured allowed hosts. Detects the wildcard opt-out value.
/// </summary>
private static HashSet<string> BuildTrustedHostSet(IReadOnlyList<string>? configuredAllowedHosts, out bool allowAllHosts)
{
allowAllHosts = false;
HashSet<string> 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;
}

/// <summary>
/// Extracts the host component from an Origin header value.
/// </summary>
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;
}

/// <summary>
/// Normalizes a host name for comparison by trimming surrounding whitespace and IPv6
/// bracket delimiters. Comparisons are performed case-insensitively.
/// </summary>
private static string NormalizeHost(string host)
{
if (string.IsNullOrWhiteSpace(host))
{
return string.Empty;
}

return host.Trim().Trim('[', ']');
}
}
}
2 changes: 2 additions & 0 deletions src/Cli.Tests/ModuleInitializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ public static void Init()
VerifierSettings.IgnoreMember<GraphQLRuntimeOptions>(options => options.EnableLegacyDateTimeScalar);
// Ignore UserProvidedPath as that's not serialized in our config file.
VerifierSettings.IgnoreMember<McpRuntimeOptions>(options => options.UserProvidedPath);
// Ignore UserProvidedAllowedHosts as that's not serialized in our config file.
VerifierSettings.IgnoreMember<McpRuntimeOptions>(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(
Expand Down
40 changes: 39 additions & 1 deletion src/Config/Converters/McpRuntimeOptionsConverterFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,13 @@ internal McpRuntimeOptionsConverter(DeserializationVariableReplacementSettings?
string? path = null;
DmlToolsConfig? dmlTools = null;
string? description = null;
List<string>? 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();
Expand Down Expand Up @@ -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<string>();
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}");
}
Expand Down Expand Up @@ -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();
}
}
Expand Down
27 changes: 26 additions & 1 deletion src/Config/ObjectModel/McpRuntimeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,23 @@ public record McpRuntimeOptions
[JsonPropertyName("description")]
public string? Description { get; init; }

/// <summary>
/// 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).
/// </summary>
[JsonPropertyName("allowed-hosts")]
public List<string>? AllowedHosts { get; init; }

[JsonConstructor]
public McpRuntimeOptions(
bool? Enabled = null,
string? Path = null,
DmlToolsConfig? DmlTools = null,
string? Description = null)
string? Description = null,
List<string>? AllowedHosts = null)
{
this.Enabled = Enabled ?? true;

Expand All @@ -67,6 +78,12 @@ public McpRuntimeOptions(
}

this.Description = Description;

if (AllowedHosts is not null)
{
this.AllowedHosts = AllowedHosts;
UserProvidedAllowedHosts = true;
}
}

/// <summary>
Expand All @@ -78,4 +95,12 @@ public McpRuntimeOptions(
[JsonIgnore(Condition = JsonIgnoreCondition.Always)]
[MemberNotNullWhen(true, nameof(Enabled))]
public bool UserProvidedPath { get; init; } = false;

/// <summary>
/// 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.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.Always)]
public bool UserProvidedAllowedHosts { get; init; } = false;
}
Loading