Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
/// An optional callback that provides an <see cref="HttpClient"/> for each MCP server.
/// The callback receives (serverUrl, cancellationToken) and should return an HttpClient
/// configured with any required authentication. Return <see langword="null"/> to use a default HttpClient with no auth.
/// <para>
/// Security: HttpClients created by this handler pin credential headers to the configured server
/// origin and disable auto-redirect. When you supply your own <see cref="HttpClient"/>, you are
/// responsible for equivalent protection — attach credentials only for the configured server origin
/// (for example via a <see cref="DelegatingHandler"/>) so a server-advertised endpoint or redirect on
/// a different origin cannot capture the Authorization token.
/// </para>
/// </param>
public DefaultMcpToolHandler(Func<string, CancellationToken, Task<HttpClient?>>? httpClientProvider = null)
{
Expand Down Expand Up @@ -201,9 +208,25 @@ private async Task<McpClient> CreateClientAsync(
// Disable cookies so handler-level state (cookie jar) cannot cross the cache-key
// isolation boundary established by GetOrCreateClientAsync. The actual MCP auth
// travels via AdditionalHeaders (set per-transport below), not session cookies.
// Disable auto-redirect so an HTTP redirect cannot carry credential headers to a
// different origin (defense-in-depth alongside the origin pinning applied below).
// CheckCertificateRevocationList satisfies CA5399 since we're explicitly constructing the handler.
HttpClientHandler handler = new() { UseCookies = false, CheckCertificateRevocationList = true };
httpClient = new HttpClient(handler);
HttpClientHandler handler = new()
{
UseCookies = false,
AllowAutoRedirect = false,
CheckCertificateRevocationList = true
};

// Pin credential headers to the configured server origin as defense-in-depth. Forcing
// StreamableHttp (below) already removes the primary vector (a server-advertised cross-origin
// SSE message endpoint), and AllowAutoRedirect=false blocks auto-redirects. This handler is the
// backstop: it guarantees the Authorization token and other credentials never leave the pinned
// origin even if a future change re-enables AutoDetect or redirects, or the SDK constructs a
// request to a new URI (AdditionalHeaders are re-stamped by the transport, so HttpClient's own
// redirect header-stripping does not cover them).
OriginPinningHandler pinningHandler = new(new Uri(serverUrl)) { InnerHandler = handler };
httpClient = new HttpClient(pinningHandler);
this._ownedHttpClients[httpClientCacheKey] = httpClient;
}

Expand All @@ -212,7 +235,11 @@ private async Task<McpClient> CreateClientAsync(
Endpoint = new Uri(serverUrl),
Name = serverLabel ?? "McpClient",
AdditionalHeaders = headers,
TransportMode = HttpTransportMode.AutoDetect
// Use Streamable HTTP rather than AutoDetect so the client does not negotiate down to the
// legacy HTTP+SSE transport, which trusts a server-advertised message endpoint. That
// server-controlled endpoint is the primary vector for redirecting the Authorization token
// to a different origin; Streamable HTTP keeps every request on the configured origin.
TransportMode = HttpTransportMode.StreamableHttp
};

HttpClientTransport transport = new(transportOptions, httpClient);
Expand Down Expand Up @@ -394,3 +421,85 @@ private static string SerializeToolsList(IEnumerable<Tool> tools)
return Encoding.UTF8.GetString(stream.GetBuffer(), 0, (int)stream.Length);
}
}

/// <summary>
/// A <see cref="DelegatingHandler"/> that strips credential-bearing headers from any outbound request
/// whose origin does not match the origin the MCP client was configured with.
/// </summary>
/// <remarks>
/// <para>
/// This pins the Authorization token (and other credential headers) to the trusted server origin so a
/// compromised or malicious MCP server cannot redirect them to a different origin — for example by
/// advertising a cross-origin SSE message endpoint or issuing an HTTP redirect. Same-origin requests are
/// left untouched so legitimate MCP traffic continues to carry its credentials.
/// </para>
/// <para>
/// This is a defense-in-depth control. Using <see cref="HttpTransportMode.StreamableHttp"/> already removes
/// the primary vector (a server-advertised cross-origin message endpoint) and disabling auto-redirect blocks
/// redirect-based leakage; this handler backstops both so credentials stay pinned even if those settings
/// change or the SDK builds a request to a new URI (<c>AdditionalHeaders</c> are re-stamped by the transport
/// and are therefore not covered by <see cref="HttpClient"/>'s own redirect header-stripping).
/// </para>
/// <para>
/// Caveat for future maintainers: this strips credentials on <em>every</em> cross-origin request. That is safe
/// today because <see cref="DefaultMcpToolHandler"/> carries auth via static request headers, not the SDK's
/// built-in OAuth flow. If OAuth support is ever added here, requests to the authorization server (a different
/// origin than the MCP resource server) legitimately carry credentials, so those auth-server origins would need
/// to be allow-listed to avoid breaking the token exchange.
/// </para>
/// </remarks>
internal sealed class OriginPinningHandler : DelegatingHandler
{
// Credential-bearing headers that must never cross the pinned-origin boundary.
private static readonly string[] s_credentialHeaderNames =
[
"Authorization",
"Proxy-Authorization",
"Cookie",
];

private readonly string _pinnedOrigin;

/// <summary>
/// Initializes a new instance of the <see cref="OriginPinningHandler"/> class.
/// </summary>
/// <param name="pinnedEndpoint">The configured MCP server endpoint whose origin credentials are pinned to.</param>
public OriginPinningHandler(Uri pinnedEndpoint)
{
Throw.IfNull(pinnedEndpoint);
this._pinnedOrigin = pinnedEndpoint.GetLeftPart(UriPartial.Authority);
}

/// <inheritdoc/>
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
StripCredentialHeadersOnCrossOrigin(request, this._pinnedOrigin);
return base.SendAsync(request, cancellationToken);
}

/// <summary>
/// Removes credential headers from <paramref name="request"/> when its origin differs from
/// <paramref name="pinnedOrigin"/>. Same-origin requests are left untouched. Origin comparison covers
/// scheme, host, and port (case-insensitive), matching the standard web-origin definition.
/// </summary>
internal static void StripCredentialHeadersOnCrossOrigin(HttpRequestMessage request, string pinnedOrigin)
{
Throw.IfNull(request);

if (request.RequestUri is not { } requestUri)
{
return;
}

string requestOrigin = requestUri.GetLeftPart(UriPartial.Authority);
if (string.Equals(requestOrigin, pinnedOrigin, StringComparison.OrdinalIgnoreCase))
{
return;
}
Comment thread
TaoChenOSU marked this conversation as resolved.
Outdated

foreach (string headerName in s_credentialHeaderNames)
{
request.Headers.Remove(headerName);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -936,4 +936,145 @@ public void ConvertContentBlock_BlockWithMeta_ShouldPropagateToAdditionalPropert
}

#endregion

#region Origin Pinning Tests

[Fact]
public void StripCredentialHeadersOnCrossOrigin_SameOrigin_RetainsAuthorization()
{
// Arrange
using HttpRequestMessage request = new(HttpMethod.Post, "https://trusted.example.com/mcp/message");
request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token");

// Act
OriginPinningHandler.StripCredentialHeadersOnCrossOrigin(request, "https://trusted.example.com");

// Assert — same origin, credential is preserved
request.Headers.Contains("Authorization").Should().BeTrue();
}

[Fact]
public void StripCredentialHeadersOnCrossOrigin_DifferentHost_RemovesAuthorization()
{
// Arrange — server-advertised endpoint on an attacker origin
using HttpRequestMessage request = new(HttpMethod.Post, "https://attacker.example/collect");
request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token");

// Act
OriginPinningHandler.StripCredentialHeadersOnCrossOrigin(request, "https://trusted.example.com");

// Assert — credential must not cross the origin boundary
request.Headers.Contains("Authorization").Should().BeFalse();
}

[Fact]
public void StripCredentialHeadersOnCrossOrigin_DifferentPort_RemovesAuthorization()
{
// Arrange — same host but a different port is a different origin
using HttpRequestMessage request = new(HttpMethod.Post, "https://trusted.example.com:8443/mcp");
request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token");

// Act
OriginPinningHandler.StripCredentialHeadersOnCrossOrigin(request, "https://trusted.example.com");

// Assert
request.Headers.Contains("Authorization").Should().BeFalse();
}

[Fact]
public void StripCredentialHeadersOnCrossOrigin_DifferentScheme_RemovesAuthorization()
{
// Arrange — downgrade to http is a different origin (and would leak over plaintext)
using HttpRequestMessage request = new(HttpMethod.Post, "http://trusted.example.com/mcp");
request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token");

// Act
OriginPinningHandler.StripCredentialHeadersOnCrossOrigin(request, "https://trusted.example.com");

// Assert
request.Headers.Contains("Authorization").Should().BeFalse();
}

[Fact]
public void StripCredentialHeadersOnCrossOrigin_CrossOrigin_RemovesCookieAndProxyAuthorization()
{
// Arrange
using HttpRequestMessage request = new(HttpMethod.Post, "https://attacker.example/collect");
request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token");
request.Headers.TryAddWithoutValidation("Cookie", "session=abc");
request.Headers.TryAddWithoutValidation("Proxy-Authorization", "Bearer proxy-token");

// Act
OriginPinningHandler.StripCredentialHeadersOnCrossOrigin(request, "https://trusted.example.com");

// Assert — all credential-bearing headers are stripped
request.Headers.Contains("Authorization").Should().BeFalse();
request.Headers.Contains("Cookie").Should().BeFalse();
request.Headers.Contains("Proxy-Authorization").Should().BeFalse();
}

[Fact]
public void StripCredentialHeadersOnCrossOrigin_CrossOrigin_PreservesNonCredentialHeaders()
{
// Arrange
using HttpRequestMessage request = new(HttpMethod.Post, "https://attacker.example/collect");
request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token");
request.Headers.TryAddWithoutValidation("X-Trace-Id", "trace-123");

// Act
OriginPinningHandler.StripCredentialHeadersOnCrossOrigin(request, "https://trusted.example.com");

// Assert — only credential headers are removed; other headers are untouched
request.Headers.Contains("Authorization").Should().BeFalse();
request.Headers.Contains("X-Trace-Id").Should().BeTrue();
}

[Fact]
public async Task OriginPinningHandler_CrossOriginRequest_DoesNotForwardAuthorizationAsync()
{
// Arrange — capture what the inner handler actually receives on the wire
CapturingHandler inner = new();
using OriginPinningHandler pinning = new(new Uri("https://trusted.example.com/mcp")) { InnerHandler = inner };
using HttpMessageInvoker invoker = new(pinning);

using HttpRequestMessage request = new(HttpMethod.Post, "https://attacker.example/collect");
request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token");

// Act
using HttpResponseMessage response = await invoker.SendAsync(request, CancellationToken.None);
Comment thread
TaoChenOSU marked this conversation as resolved.

// Assert — the credential never reached the inner handler for the foreign origin
inner.LastRequestHadAuthorization.Should().BeFalse();
}

[Fact]
public async Task OriginPinningHandler_SameOriginRequest_ForwardsAuthorizationAsync()
{
// Arrange
CapturingHandler inner = new();
using OriginPinningHandler pinning = new(new Uri("https://trusted.example.com/mcp")) { InnerHandler = inner };
using HttpMessageInvoker invoker = new(pinning);

using HttpRequestMessage request = new(HttpMethod.Post, "https://trusted.example.com/mcp/message");
request.Headers.TryAddWithoutValidation("Authorization", "Bearer secret-token");

// Act
using HttpResponseMessage response = await invoker.SendAsync(request, CancellationToken.None);
Comment thread
TaoChenOSU marked this conversation as resolved.

// Assert — same-origin credential flows through normally
inner.LastRequestHadAuthorization.Should().BeTrue();
}

private sealed class CapturingHandler : HttpMessageHandler
{
public bool LastRequestHadAuthorization { get; private set; }

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
this.LastRequestHadAuthorization = request.Headers.Contains("Authorization");
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK));
}
}

#endregion
}
Loading