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
33 changes: 31 additions & 2 deletions docs/concepts/transports/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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<MyTools>();
```

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
Expand Down
36 changes: 36 additions & 0 deletions src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,42 @@ public bool Stateless
set => SessionMode = value ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful;
}

/// <summary>
/// Gets or sets additional browser origins allowed to call the MCP endpoints in addition to the
/// same-origin and loopback defaults.
/// </summary>
/// <remarks>
/// <para>
/// By default, <see cref="M:McpEndpointRouteBuilderExtensions.MapMcp"/> validates the <c>Origin</c> header on
/// browser requests to the MCP endpoints, protecting servers from DNS rebinding attacks. Requests without an
/// <c>Origin</c> header (such as SDK clients and <c>curl</c>), requests whose origin's host and port match the
/// request's <c>Host</c> header, and loopback origins (<c>localhost</c>, <c>127.0.0.1</c>, <c>[::1]</c>) are
/// allowed without configuration. Any other cross-origin request is rejected with <c>403 Forbidden</c>.
/// </para>
/// <para>
/// 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 (<c>scheme://host[:port]</c>) 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.
/// </para>
/// </remarks>
public IList<string> AllowedOrigins { get; set; } = [];

/// <summary>
/// Gets or sets a value that indicates whether the MCP endpoints validate the <c>Origin</c> header of browser requests.
/// </summary>
/// <value>
/// <see langword="true"/> to skip origin validation and accept requests from any origin;
/// <see langword="false"/> to reject cross-origin browser requests that are not explicitly allowed.
/// The default is <see langword="false"/>.
/// </value>
/// <remarks>
/// 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.
/// </remarks>
public bool DisableOriginValidation { get; set; }

/// <summary>
/// Gets or sets a value that indicates whether the server maps legacy SSE endpoints (<c>/sse</c> and <c>/message</c>)
/// for backward compatibility with clients that do not support the Streamable HTTP transport.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,6 +23,13 @@ public static class McpEndpointRouteBuilderExtensions
/// <remarks>
/// For details about the Streamable HTTP transport, see the <see href="https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http">2025-11-25 protocol specification</see>.
/// When legacy SSE is enabled via <see cref="HttpServerTransportOptions.EnableLegacySse"/>, this method also maps legacy SSE endpoints at the path "/sse" and "/message". For details about the HTTP with SSE transport, see the <see href="https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse">2024-11-05 protocol specification</see>.
/// <para>
/// By default, the mapped endpoints validate the <c>Origin</c> header of browser requests: requests without an
/// <c>Origin</c> header, same-origin requests, and loopback origins are allowed, and other cross-origin requests
/// are rejected with <c>403 Forbidden</c>. Additional allowed origins can be configured via
/// <see cref="HttpServerTransportOptions.AllowedOrigins"/>, and validation can be disabled entirely via
/// <see cref="HttpServerTransportOptions.DisableOriginValidation"/>.
/// </para>
/// </remarks>
public static IEndpointConventionBuilder MapMcp(this IEndpointRouteBuilder endpoints, [StringSyntax("Route")] string pattern = "")
{
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using Microsoft.AspNetCore.Http;

namespace ModelContextProtocol.AspNetCore;

/// <summary>
/// Rejects requests whose <c>Origin</c> header cannot be verified against the server or its configured
/// allowed origins, protecting MCP endpoints from cross-origin browser requests (including DNS rebinding).
/// </summary>
/// <remarks>
/// A request is allowed when it has no <c>Origin</c> header (non-browser clients such as SDK clients and
/// <c>curl</c>), when the origin's host and port match the request's <c>Host</c> header, when the origin is a
/// loopback address (<c>localhost</c>, <c>127.0.0.1</c>, <c>[::1]</c>), or when the origin is listed in
/// <see cref="HttpServerTransportOptions.AllowedOrigins"/>. Any other request with an <c>Origin</c> header is
/// rejected with <c>403 Forbidden</c>.
/// </remarks>
internal sealed class OriginValidationEndpointFilter(HttpServerTransportOptions options) : IEndpointFilter
{
/// <inheritdoc/>
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
string? origin = context.HttpContext.Request.Headers.Origin;
if (!string.IsNullOrEmpty(origin) && !IsOriginAllowed(context.HttpContext, origin))
{
return ValueTask.FromResult<object?>(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;
}
Loading