-
Notifications
You must be signed in to change notification settings - Fork 356
Add configurable Host/Origin allowlist for the MCP endpoint #3736
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
souvikghosh04
wants to merge
12
commits into
main
Choose a base branch
from
mcp-allowed-hosts-validation
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
fb578e1
Add configurable Host and Origin allowlist for the MCP endpoint
souvikghosh04 0fd59c2
Exclude UserProvidedAllowedHosts from Verify snapshots
souvikghosh04 eaaae18
Merge branch 'main' into mcp-allowed-hosts-validation
souvikghosh04 53c72e1
Potential fix for pull request finding
souvikghosh04 48246e0
Potential fix for pull request finding
souvikghosh04 2ca3131
Potential fix for pull request finding
souvikghosh04 a84136c
format whitespace
souvikghosh04 294f5a3
Merge branch 'mcp-allowed-hosts-validation' of https://github.com/Azu…
souvikghosh04 18ee91d
Merge branch 'main' into mcp-allowed-hosts-validation
anushakolan 35e6c92
Merge branch 'main' into mcp-allowed-hosts-validation
souvikghosh04 be5722a
Address review feedback on MCP host allowlist
souvikghosh04 85d8a0d
Merge branch 'main' into mcp-allowed-hosts-validation
souvikghosh04 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
213 changes: 213 additions & 0 deletions
213
src/Azure.DataApiBuilder.Mcp/Core/McpDnsRebindingProtectionMiddleware.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using Azure.DataApiBuilder.Config.ObjectModel; | ||
| using Azure.DataApiBuilder.Core.Configurations; | ||
| using Microsoft.AspNetCore.Http; | ||
| using Microsoft.Extensions.Primitives; | ||
|
|
||
| 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)) | ||
|
souvikghosh04 marked this conversation as resolved.
|
||
| { | ||
| 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 StringValues originValues)) | ||
| { | ||
| string? originValue = originValues.ToString(); | ||
| if (!string.IsNullOrEmpty(originValue)) | ||
| { | ||
| if (!TryGetOriginHost(originValue, out string originHost) | ||
| || !trustedHosts.Contains(originHost)) | ||
|
souvikghosh04 marked this conversation as resolved.
|
||
| { | ||
| 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('[', ']'); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.