Skip to content
Open
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": {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears to be a new feature to fix the issue. So I think that should be added to the PR description.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see anywhere where this new property can be added or edited through the CLI. Will that be added in a different PR?

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't using Contains might cause a case were an incomplete hostHeaderValue still be accepted even though its incomplete?
For example if it were equal to local then it would still be accepted through localhost. I think we should be checking each trustedHost against the hostHeaderValue individually and ensure it is exactly the same.

{
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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think it is better to add the namespace in the using section instead of writing the complete namespace.

{
string? originValue = originValues.ToString();
if (!string.IsNullOrEmpty(originValue))
{
if (!TryGetOriginHost(originValue, out string originHost)
|| !trustedHosts.Contains(originHost))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as mentioned previously with Contains

{
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) &&

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we use the normalizedHost when comparing against the * sign?

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