Add configurable Host/Origin allowlist for the MCP endpoint - #3736
Add configurable Host/Origin allowlist for the MCP endpoint#3736souvikghosh04 wants to merge 10 commits into
Conversation
Introduces runtime.mcp.allowed-hosts so operators can restrict which Host and Origin header values are accepted by the MCP Streamable HTTP endpoint. Loopback hosts are always trusted; a single '*' entry preserves prior behavior. Adds validation middleware, config plumbing, schema, and unit tests.
The new UserProvidedAllowedHosts helper flag is not serialized to the config file, mirroring UserProvidedPath. Ignore it in the Cli.Tests and Service.Tests Verify module initializers so snapshot tests remain stable.
There was a problem hiding this comment.
Pull request overview
Adds a defense-in-depth DNS rebinding mitigation for the browser-reachable MCP Streamable HTTP endpoint by introducing a configurable Host/Origin allowlist in runtime config, plus schema and test coverage.
Changes:
- Introduces
runtime.mcp.allowed-hostsand serializes it only when explicitly user-provided. - Adds
McpDnsRebindingProtectionMiddlewareand wires it into the Service middleware pipeline for requests targeting the MCP path. - Updates JSON schema and adds unit/serialization tests for the new behavior and config round-tripping.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Service/Startup.cs | Inserts the MCP DNS rebinding protection middleware into the request pipeline. |
| src/Azure.DataApiBuilder.Mcp/Core/McpDnsRebindingProtectionMiddleware.cs | Implements Host/Origin validation (loopback trusted, "*" opt-out) for MCP path requests. |
| src/Config/ObjectModel/McpRuntimeOptions.cs | Adds AllowedHosts and a UserProvidedAllowedHosts flag for controlled serialization. |
| src/Config/Converters/McpRuntimeOptionsConverterFactory.cs | Deserializes/serializes allowed-hosts gated by the user-provided flag. |
| src/Service.Tests/Mcp/McpDnsRebindingProtectionMiddlewareTests.cs | Adds unit tests for trust decisions and middleware pipeline behavior. |
| src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs | Adds serialization/deserialization coverage for allowed-hosts. |
| src/Service.Tests/ModuleInitializer.cs | Updates snapshot settings to ignore the new non-serialized flag. |
| src/Cli.Tests/ModuleInitializer.cs | Updates snapshot settings to ignore the new non-serialized flag. |
| schemas/dab.draft.schema.json | Adds schema support and documentation for runtime.mcp.allowed-hosts. |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…re/data-api-builder into mcp-allowed-hosts-validation # Conflicts: # src/Config/Converters/McpRuntimeOptionsConverterFactory.cs
RubenCerna2079
left a comment
There was a problem hiding this comment.
Change logic that checks allowed Hosts using Contains function in order to ensure that it doesn't approve incomplete Hosts accidentally.
| "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": { |
There was a problem hiding this comment.
This appears to be a new feature to fix the issue. So I think that should be added to the PR description.
| }, | ||
| "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.", |
There was a problem hiding this comment.
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?
| // 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)) |
There was a problem hiding this comment.
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.
| } | ||
|
|
||
| // Validate the Origin header when present (browser-initiated cross-origin requests). | ||
| if (request.Headers.TryGetValue("Origin", out Microsoft.Extensions.Primitives.StringValues originValues)) |
There was a problem hiding this comment.
nit: I think it is better to add the namespace in the using section instead of writing the complete namespace.
| } | ||
|
|
||
| string normalizedHost = host.Trim('[', ']'); | ||
| if (!string.Equals(host, "*", StringComparison.Ordinal) && |
There was a problem hiding this comment.
Shouldn't we use the normalizedHost when comparing against the * sign?
| if (!string.IsNullOrEmpty(originValue)) | ||
| { | ||
| if (!TryGetOriginHost(originValue, out string originHost) | ||
| || !trustedHosts.Contains(originHost)) |
There was a problem hiding this comment.
Same issue as mentioned previously with Contains
| bool allowed = McpDnsRebindingProtectionMiddleware.IsRequestFromTrustedHost( | ||
| request, configuredAllowedHosts: null, out string reason); | ||
|
|
||
| Assert.IsTrue(allowed, $"Loopback host with a port should be trusted. Reason: {reason}"); |
There was a problem hiding this comment.
nit: We should also assert the reason variable is null
| bool allowed = McpDnsRebindingProtectionMiddleware.IsRequestFromTrustedHost( | ||
| request, configuredAllowedHosts: new List<string> { "dab.internal" }, out string reason); | ||
|
|
||
| Assert.IsTrue(allowed, $"Configured host should be trusted. Reason: {reason}"); |
There was a problem hiding this comment.
nit: Same as comment above
| bool allowed = McpDnsRebindingProtectionMiddleware.IsRequestFromTrustedHost( | ||
| request, configuredAllowedHosts: null, out string reason); | ||
|
|
||
| Assert.IsTrue(allowed, $"Trusted Host and Origin should be allowed. Reason: {reason}"); |
There was a problem hiding this comment.
nit: Same as previous comment
| bool allowed = McpDnsRebindingProtectionMiddleware.IsRequestFromTrustedHost( | ||
| request, configuredAllowedHosts: null, out _); | ||
|
|
||
| Assert.IsTrue(allowed, "A request with a trusted Host and no Origin should be allowed."); |
There was a problem hiding this comment.
nit: Same as previous comment
Why
The MCP Streamable HTTP endpoint is reachable from browsers. Today it accepts requests regardless of the incoming
Host/Originheader, which is broader than necessary for a locally- or internally-hosted endpoint. This change adds defense-in-depth by letting operators constrain which hosts and origins may reach the MCP endpoint, with a safe default.What
runtime.mcp.allowed-hosts(array of host names).Hostheader — and theOriginheader when present — for requests targeting the configured MCP path.localhost,127.0.0.1,::1) are always trusted so local MCP clients keep working with no additional configuration."*"entry opts out of validation, preserving prior behavior for deployments that terminate host validation upstream.403 Forbiddenbefore reaching the MCP transport.Behavior change
Deployments that expose the MCP endpoint on a non-loopback host name must add that host name to
runtime.mcp.allowed-hosts(or set"*"to keep the previous behavior).Testing
allowed-hostsproperty.dotnet format --verify-no-changesis clean on the changed files.