From 9c8e4c6520eb46b8b0f59cfa4d4b57bc1fd82f44 Mon Sep 17 00:00:00 2001 From: meraklbz Date: Sun, 16 Aug 2026 23:08:40 +0800 Subject: [PATCH] feat: add Origin header validation to MapMcp endpoints --- docs/concepts/transports/transports.md | 33 +++- .../HttpServerTransportOptions.cs | 36 ++++ .../McpEndpointRouteBuilderExtensions.cs | 18 +- .../OriginValidationEndpointFilter.cs | 78 ++++++++ .../OriginValidationTests.cs | 173 ++++++++++++++++++ 5 files changed, 335 insertions(+), 3 deletions(-) create mode 100644 src/ModelContextProtocol.AspNetCore/OriginValidationEndpointFilter.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/OriginValidationTests.cs diff --git a/docs/concepts/transports/transports.md b/docs/concepts/transports/transports.md index bb4e155f2..d707c16a0 100644 --- a/docs/concepts/transports/transports.md +++ b/docs/concepts/transports/transports.md @@ -192,6 +192,8 @@ By default, the HTTP transport runs **statelessly** — the server does not assi For local HTTP servers, keep the set of accepted host names limited to loopback values. This helps protect against DNS rebinding, where a browser reaches a local server through an attacker-controlled DNS name while sending that DNS name in the HTTP `Host` header. ASP.NET Core's Kestrel server doesn't validate `Host` headers by default, so configure `AllowedHosts` with known host names rather than `"*"`. This also avoids reflecting untrusted host names through ASP.NET Core features such as absolute URL generation. See [Host filtering with ASP.NET Core Kestrel web server | Microsoft Learn](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/host-filtering) and [URL generation concepts | Microsoft Learn](https://learn.microsoft.com/aspnet/core/fundamentals/routing#url-generation-concepts). +`MapMcp` additionally validates the `Origin` header of browser requests by default; see [Origin validation](#origin-validation). + ```json // appsettings.Development.json { @@ -203,15 +205,42 @@ For production servers, configure `AllowedHosts` to the exact public host names If you intentionally expose the server through another host name, such as a tunnel, container host, reverse proxy, or deployed domain, add that exact host name to `AllowedHosts` instead of using `"*"`. +#### Origin validation + +`MapMcp` validates the `Origin` header of browser requests by default, protecting servers from [DNS rebinding] attacks where a browser reaches the server through an attacker-controlled DNS name. Requests without an `Origin` header — SDK clients, `curl`, and other non-browser callers — are always allowed. Requests that carry an `Origin` header are accepted when: + +- The origin's host and port match the request's `Host` header (same-origin). +- The origin is a loopback address (`localhost`, `127.0.0.1`, `[::1]`), so browsers running on the same machine (for example, a frontend dev server) can reach the server without configuration. +- The origin is listed in `HttpServerTransportOptions.AllowedOrigins`. + +[DNS rebinding]: https://owasp.org/www-community/attacks/DNS_Rebinding + +Any other cross-origin request is rejected with `403 Forbidden`. + +To allow a browser client served from a different origin to call the server, add that client's origin to `AllowedOrigins` and configure a matching CORS policy (see [Browser cross-origin access](#browser-cross-origin-access)). Entries are absolute origins (`scheme://host[:port]`) matched case-insensitively: + +```csharp +builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.AllowedOrigins.Add("https://app.example.com"); + }) + .WithTools(); +``` + +Origin validation can be disabled entirely by setting `HttpServerTransportOptions.DisableOriginValidation` to `true`. Only do this when the server is not reachable from a browser or when equivalent protection (such as a reverse proxy that validates origins) is already in place. + #### Browser cross-origin access +Origin validation (see above) rejects cross-origin requests at the server. CORS is a separate mechanism that controls whether a browser may **read** the server's responses, and the browser's preflight (`OPTIONS`) requests are answered by the CORS middleware itself before the endpoint runs. + **Only** enable cross-origin requests (CORS) if you intentionally want browser-based cross-origin access to this server. -CORS is not a substitute for host name validation. When browser-based cross-origin access is required, limit which browser origins can call the MCP endpoint by using the most restrictive ASP.NET Core CORS policy possible. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). +CORS is not a substitute for host name validation. When browser-based cross-origin access is required, limit which browser origins can call the MCP endpoint by using the most restrictive ASP.NET Core CORS policy possible, and add the same origins to `AllowedOrigins` so the actual requests pass origin validation. See [Enable Cross-Origin Requests (CORS) in ASP.NET Core | Microsoft Learn](https://learn.microsoft.com/aspnet/core/security/cors). For a **stateless** browser client, a narrowly scoped CORS policy usually only needs the headers the browser would otherwise preflight: `Content-Type` for JSON, `Authorization` when the endpoint is protected, and `MCP-Protocol-Version`. If you enable sessions or resumability, also allow `Mcp-Session-Id` and `Last-Event-ID`, and expose `Mcp-Session-Id` on responses so browser code can read it. `Accept` normally doesn't need to be listed because browsers can already send it without extra CORS configuration. -_In the following sample, the MCP server will allow browser calls from `localhost:5173` where a web application is making the request. In production, this allowed origin list would be configured to the trusted web application domains._ +_In the following sample, the MCP server will allow browser calls from `localhost:5173` where a web application is making the request. In production, this allowed origin list would be configured to the trusted web application domains. The `localhost:5173` origin is loopback, so it passes [origin validation](#origin-validation) by default; the CORS policy below is what lets the browser read the server's responses._ ```json // appsettings.Development.json diff --git a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs index 7ecfc0748..3e1a86a13 100644 --- a/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs +++ b/src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs @@ -114,6 +114,42 @@ public bool Stateless set => SessionMode = value ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful; } + /// + /// Gets or sets additional browser origins allowed to call the MCP endpoints in addition to the + /// same-origin and loopback defaults. + /// + /// + /// + /// By default, validates the Origin header on + /// browser requests to the MCP endpoints, protecting servers from DNS rebinding attacks. Requests without an + /// Origin header (such as SDK clients and curl), requests whose origin's host and port match the + /// request's Host header, and loopback origins (localhost, 127.0.0.1, [::1]) are + /// allowed without configuration. Any other cross-origin request is rejected with 403 Forbidden. + /// + /// + /// Origins listed here are allowed in addition to those defaults, enabling a browser-hosted client served from + /// a different origin to call the server. Each entry is an absolute origin (scheme://host[:port]) and is + /// matched case-insensitively, mirroring how ASP.NET Core's CORS middleware matches origins. A matching CORS + /// policy is still required for the browser to read the server's responses. + /// + /// + public IList AllowedOrigins { get; set; } = []; + + /// + /// Gets or sets a value that indicates whether the MCP endpoints validate the Origin header of browser requests. + /// + /// + /// to skip origin validation and accept requests from any origin; + /// to reject cross-origin browser requests that are not explicitly allowed. + /// The default is . + /// + /// + /// Disable origin validation only when the server is not reachable from a browser, or when equivalent + /// protection — such as a reverse proxy that validates origins, or a per-endpoint authorization policy — + /// is already in place. + /// + public bool DisableOriginValidation { get; set; } + /// /// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (/sse and /message) /// for backward compatibility with clients that do not support the Streamable HTTP transport. diff --git a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs index e5fc3fa4d..c883e84be 100644 --- a/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs +++ b/src/ModelContextProtocol.AspNetCore/McpEndpointRouteBuilderExtensions.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -23,6 +23,13 @@ public static class McpEndpointRouteBuilderExtensions /// /// For details about the Streamable HTTP transport, see the 2025-11-25 protocol specification. /// When legacy SSE is enabled via , this method also maps legacy SSE endpoints at the path "/sse" and "/message". For details about the HTTP with SSE transport, see the 2024-11-05 protocol specification. + /// + /// By default, the mapped endpoints validate the Origin header of browser requests: requests without an + /// Origin header, same-origin requests, and loopback origins are allowed, and other cross-origin requests + /// are rejected with 403 Forbidden. Additional allowed origins can be configured via + /// , and validation can be disabled entirely via + /// . + /// /// public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "") { @@ -80,6 +87,15 @@ public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpo } } + // By default, validate the Origin header of browser requests to the MCP endpoints to protect + // against DNS rebinding. Requests without an Origin header (SDK clients, curl) are always allowed; + // see OriginValidationEndpointFilter for the exact rules. Disable via + // HttpServerTransportOptions.DisableOriginValidation. + if (!options.DisableOriginValidation) + { + mcpGroup.AddEndpointFilter(new OriginValidationEndpointFilter(options)); + } + return mcpGroup; } } diff --git a/src/ModelContextProtocol.AspNetCore/OriginValidationEndpointFilter.cs b/src/ModelContextProtocol.AspNetCore/OriginValidationEndpointFilter.cs new file mode 100644 index 000000000..f504855de --- /dev/null +++ b/src/ModelContextProtocol.AspNetCore/OriginValidationEndpointFilter.cs @@ -0,0 +1,78 @@ +using Microsoft.AspNetCore.Http; + +namespace ModelContextProtocol.AspNetCore; + +/// +/// Rejects requests whose Origin header cannot be verified against the server or its configured +/// allowed origins, protecting MCP endpoints from cross-origin browser requests (including DNS rebinding). +/// +/// +/// A request is allowed when it has no Origin header (non-browser clients such as SDK clients and +/// curl), when the origin's host and port match the request's Host header, when the origin is a +/// loopback address (localhost, 127.0.0.1, [::1]), or when the origin is listed in +/// . Any other request with an Origin header is +/// rejected with 403 Forbidden. +/// +internal sealed class OriginValidationEndpointFilter(HttpServerTransportOptions options) : IEndpointFilter +{ + /// + public ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + string? origin = context.HttpContext.Request.Headers.Origin; + if (!string.IsNullOrEmpty(origin) && !IsOriginAllowed(context.HttpContext, origin)) + { + return ValueTask.FromResult(Results.StatusCode(StatusCodes.Status403Forbidden)); + } + + return next(context); + } + + private bool IsOriginAllowed(HttpContext httpContext, string origin) + { + // Explicitly configured origins win and are matched exactly, case-insensitively, like the CORS middleware. + foreach (string allowedOrigin in options.AllowedOrigins) + { + if (string.Equals(origin, allowedOrigin, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + if (!Uri.TryCreate(origin, UriKind.Absolute, out Uri? originUri) || originUri.Host.Length == 0) + { + return false; + } + + // Loopback origins are always allowed so browsers running on the same machine (for example, a frontend + // dev server on localhost:5173) can reach the server without extra configuration. + if (IsLoopbackHost(originUri.Host)) + { + return true; + } + + // Allow the request when the origin's host and port match the request's Host header. The scheme is + // intentionally not compared: TLS is often terminated by a reverse proxy, which leaves the request + // scheme as http while the browser origin uses https. + return HostMatchesRequest(httpContext.Request, originUri); + } + + private static bool HostMatchesRequest(HttpRequest request, Uri originUri) + { + HostString requestHost = request.Host; + if (requestHost.Host.Length == 0 || !string.Equals(requestHost.Host, originUri.Host, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + int requestPort = requestHost.Port ?? DefaultPort(request.Scheme); + int originPort = originUri.IsDefaultPort ? DefaultPort(originUri.Scheme) : originUri.Port; + return requestPort == originPort; + } + + private static bool IsLoopbackHost(string host) + => string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || + host is "127.0.0.1" or "::1" or "[::1]"; + + private static int DefaultPort(string scheme) + => string.Equals(scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) ? 443 : 80; +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OriginValidationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OriginValidationTests.cs new file mode 100644 index 000000000..ac953ed75 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OriginValidationTests.cs @@ -0,0 +1,173 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Protocol; +using System.Net; +using System.Text; +using System.Threading.Tasks; + +namespace ModelContextProtocol.AspNetCore.Tests; + +/// +/// Tests for the default Origin header validation applied by +/// (see and +/// ). +/// +public class OriginValidationTests(ITestOutputHelper outputHelper) : KestrelInMemoryTest(outputHelper), IAsyncDisposable +{ + private WebApplication? _app; + + private async Task StartAsync(Action? configureTransport = null) + { + Builder.Services.AddMcpServer(options => + { + options.ServerInfo = new() + { + Name = "OriginValidationTestServer", + Version = "1.0.0", + }; + }).WithHttpTransport(configureTransport); + + _app = Builder.Build(); + _app.MapMcp(); + await _app.StartAsync(TestContext.Current.CancellationToken); + + HttpClient.DefaultRequestHeaders.Accept.Add(new("application/json")); + HttpClient.DefaultRequestHeaders.Accept.Add(new("text/event-stream")); + } + + public async ValueTask DisposeAsync() + { + if (_app is not null) + { + await _app.DisposeAsync(); + } + base.Dispose(); + } + + private static HttpRequestMessage CreateServerDiscoverRequest(string? origin = null) + { + const string discoverRequest = """ + {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"OriginValidationTestClient","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}} + """; + + var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5000/") + { + Content = new StringContent(discoverRequest, Encoding.UTF8, "application/json"), + }; + request.Headers.Add("MCP-Protocol-Version", McpProtocolVersions.July2026ProtocolVersion); + request.Headers.Add("Mcp-Method", "server/discover"); + if (origin is not null) + { + request.Headers.Add("Origin", origin); + } + return request; + } + + [Fact] + public async Task Request_WithoutOriginHeader_IsAllowed() + { + await StartAsync(); + + using var response = await HttpClient.SendAsync(CreateServerDiscoverRequest(), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Request_WithSameOrigin_IsAllowed() + { + await StartAsync(); + + // The origin's host and port match the request's Host header (localhost:5000). + using var response = await HttpClient.SendAsync(CreateServerDiscoverRequest(origin: "http://localhost:5000"), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Request_WithSameHost_ButDifferentScheme_IsAllowed() + { + await StartAsync(); + + // The scheme is intentionally not compared: TLS is often terminated at a reverse proxy, leaving + // the request scheme as http while the browser origin uses https. + using var response = await HttpClient.SendAsync(CreateServerDiscoverRequest(origin: "https://localhost:5000"), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Request_WithLoopbackOrigin_IsAllowed() + { + await StartAsync(); + + // A browser running on the same machine (for example a frontend dev server on localhost:5173) + // has a loopback origin that differs from the server's host and is allowed by default. + using var response = await HttpClient.SendAsync(CreateServerDiscoverRequest(origin: "http://localhost:5173"), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData("http://127.0.0.1:5173")] + [InlineData("http://[::1]:5173")] + public async Task Request_WithOtherLoopbackOrigin_IsAllowed(string origin) + { + await StartAsync(); + + using var response = await HttpClient.SendAsync(CreateServerDiscoverRequest(origin), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Request_WithCrossOrigin_IsRejected() + { + await StartAsync(); + + using var response = await HttpClient.SendAsync(CreateServerDiscoverRequest(origin: "https://evil.example.com"), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Request_WithMalformedOrigin_IsRejected() + { + await StartAsync(); + + using var response = await HttpClient.SendAsync(CreateServerDiscoverRequest(origin: "not-a-valid-origin"), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Request_WithConfiguredAllowedOrigin_IsAllowed() + { + await StartAsync(options => options.AllowedOrigins.Add("https://app.example.com")); + + using var response = await HttpClient.SendAsync(CreateServerDiscoverRequest(origin: "https://app.example.com"), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Request_WithUnconfiguredOrigin_IsStillRejected_WhenOthersAreAllowed() + { + await StartAsync(options => options.AllowedOrigins.Add("https://app.example.com")); + + using var response = await HttpClient.SendAsync(CreateServerDiscoverRequest(origin: "https://other.example.com"), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Request_WithCrossOrigin_WhenValidationDisabled_IsAllowed() + { + await StartAsync(options => options.DisableOriginValidation = true); + + using var response = await HttpClient.SendAsync(CreateServerDiscoverRequest(origin: "https://evil.example.com"), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +}