Nostos.Backend.Tests/Mcp/McpClientMetadataFetcherTests.cs
Rama main · réplica de lectura del repositorio original.
using System.Diagnostics;
using System.Net;
using System.Text;
using FluentAssertions;
using Nostos.Backend.Services.Mcp.OAuth;
using Xunit;
namespace Nostos.Backend.Tests.Mcp;
/// <summary>
/// CIMD fetch limits (issue #1470). The address filter and the client id
/// checks run on real address parsing and real DNS (for <c>localhost</c>);
/// only documents that would need the internet come from a stand-in transport.
/// </summary>
public sealed class McpClientMetadataFetcherTests
{
private const string ClientId = "https://client.example/oauth/metadata.json";
[Theory]
[InlineData("8.8.8.8", true)]
[InlineData("93.184.216.34", true)]
[InlineData("2606:4700:4700::1111", true)]
[InlineData("127.0.0.1", false)]
[InlineData("127.5.6.7", false)]
[InlineData("0.0.0.0", false)]
[InlineData("10.0.0.1", false)]
[InlineData("172.16.0.1", false)]
[InlineData("172.31.255.255", false)]
[InlineData("172.32.0.1", true)]
[InlineData("192.168.1.10", false)]
[InlineData("169.254.169.254", false)]
[InlineData("100.64.0.1", false)]
[InlineData("100.101.102.103", false)]
[InlineData("100.127.255.255", false)]
[InlineData("100.128.0.1", true)]
[InlineData("192.0.2.1", false)]
[InlineData("198.18.0.1", false)]
[InlineData("224.0.0.1", false)]
[InlineData("239.255.255.250", false)]
[InlineData("255.255.255.255", false)]
[InlineData("::1", false)]
[InlineData("::", false)]
[InlineData("fe80::1", false)]
[InlineData("fc00::1", false)]
[InlineData("fd7a:115c:a1e0::1", false)]
[InlineData("ff02::1", false)]
[InlineData("::ffff:127.0.0.1", false)]
[InlineData("::ffff:10.0.0.1", false)]
[InlineData("::ffff:8.8.8.8", true)]
[InlineData("64:ff9b::a00:1", false)]
[InlineData("2002:a00:1::", false)]
[InlineData("2001:db8::1", false)]
[InlineData("2001:0:4136:e378::1", false)]
public void Only_public_unicast_addresses_pass(string address, bool expected) =>
McpNetworkAddressFilter.IsPublic(IPAddress.Parse(address)).Should().Be(expected);
[Theory]
[InlineData("http://client.example/metadata.json")]
[InlineData("ftp://client.example/metadata.json")]
[InlineData("https://client.example/")]
[InlineData("https://user:pass@client.example/metadata.json")]
[InlineData("https://client.example/metadata.json#x")]
[InlineData("client.example/metadata.json")]
[InlineData("")]
public async Task A_client_id_that_is_not_an_https_document_url_is_refused_without_a_request(string clientId)
{
var transport = new StubTransport(_ => Json(Document(clientId)));
using var fetcher = new McpClientMetadataFetcher(transport, PublicDns, null);
var result = await fetcher.FetchAsync(clientId, CancellationToken.None);
result.Metadata.Should().BeNull();
result.Error.Should().NotBeNullOrEmpty();
transport.Requests.Should().BeEmpty();
}
[Theory]
[InlineData("https://localhost/oauth/metadata.json")]
[InlineData("https://127.0.0.1/oauth/metadata.json")]
[InlineData("https://[::1]/oauth/metadata.json")]
[InlineData("https://10.0.0.8/oauth/metadata.json")]
[InlineData("https://169.254.169.254/latest/meta-data")]
[InlineData("https://100.100.100.100/oauth/metadata.json")]
public async Task The_real_fetcher_refuses_private_targets_including_after_DNS(string clientId)
{
// The production fetcher: real DNS for localhost, real sockets, no stand-ins.
using var fetcher = new McpClientMetadataFetcher();
var result = await fetcher.FetchAsync(clientId, CancellationToken.None);
result.Metadata.Should().BeNull();
result.Error.Should().Contain("private network");
}
[Fact]
public async Task A_host_whose_DNS_answer_is_private_is_refused_before_any_request()
{
var transport = new StubTransport(_ => Json(Document(ClientId)));
using var fetcher = new McpClientMetadataFetcher(transport, (_, _) => Task.FromResult(new[] { IPAddress.Parse("10.1.2.3") }), null);
var result = await fetcher.FetchAsync(ClientId, CancellationToken.None);
result.Error.Should().Contain("private network");
transport.Requests.Should().BeEmpty();
}
[Fact]
public async Task A_host_with_one_private_address_among_public_ones_is_refused()
{
var transport = new StubTransport(_ => Json(Document(ClientId)));
using var fetcher = new McpClientMetadataFetcher(transport,
(_, _) => Task.FromResult(new[] { IPAddress.Parse("93.184.216.34"), IPAddress.Parse("127.0.0.1") }), null);
(await fetcher.FetchAsync(ClientId, CancellationToken.None)).Error.Should().Contain("private network");
transport.Requests.Should().BeEmpty();
}
[Fact]
public async Task A_redirect_to_plain_http_is_refused()
{
var transport = new StubTransport(_ => Redirect("http://client.example/plain.json"));
using var fetcher = new McpClientMetadataFetcher(transport, PublicDns, null);
var result = await fetcher.FetchAsync(ClientId, CancellationToken.None);
result.Error.Should().Contain("https");
transport.Requests.Should().ContainSingle();
}
[Fact]
public async Task A_redirect_to_a_host_that_resolves_privately_is_refused()
{
var transport = new StubTransport(request => request.RequestUri!.Host == "client.example"
? Redirect("https://inner.example/metadata.json")
: Json(Document(ClientId)));
using var fetcher = new McpClientMetadataFetcher(transport,
(host, _) => Task.FromResult(new[] { IPAddress.Parse(host == "inner.example" ? "192.168.1.5" : "93.184.216.34") }), null);
var result = await fetcher.FetchAsync(ClientId, CancellationToken.None);
result.Error.Should().Contain("private network");
transport.Requests.Select(r => r.Host).Should().Equal("client.example");
}
[Fact]
public async Task A_name_that_does_not_resolve_is_an_error_not_a_crash()
{
var transport = new StubTransport(_ => Json(Document(ClientId)));
using var fetcher = new McpClientMetadataFetcher(transport,
(_, _) => throw new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.HostNotFound), null);
var result = await fetcher.FetchAsync(ClientId, CancellationToken.None);
result.Metadata.Should().BeNull();
result.Error.Should().Contain("could not be loaded");
transport.Requests.Should().BeEmpty();
}
[Fact]
public async Task A_redirect_to_a_private_address_literal_is_refused()
{
var transport = new StubTransport(_ => Redirect("https://127.0.0.1:8443/metadata.json"));
using var fetcher = new McpClientMetadataFetcher(transport, PublicDns, null);
(await fetcher.FetchAsync(ClientId, CancellationToken.None)).Error.Should().Contain("private network");
transport.Requests.Should().ContainSingle();
}
[Fact]
public async Task Too_many_redirects_are_refused()
{
var transport = new StubTransport(request => Redirect($"https://client.example/hop{transportCount++}"));
using var fetcher = new McpClientMetadataFetcher(transport, PublicDns, null);
(await fetcher.FetchAsync(ClientId, CancellationToken.None)).Error.Should().Contain("redirects");
transport.Requests.Should().HaveCount(McpClientMetadataFetcher.MaxRedirects + 1);
}
private int transportCount;
[Fact]
public async Task A_document_over_16_KiB_is_refused_with_or_without_a_length()
{
var big = Document(ClientId).Replace("\"Claude\"", "\"" + new string('a', 20_000) + "\"");
var declared = new StubTransport(_ => Json(big));
var streamed = new StubTransport(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StreamContent(new UnseekableStream(Encoding.UTF8.GetBytes(big))),
});
using var a = new McpClientMetadataFetcher(declared, PublicDns, null);
using var b = new McpClientMetadataFetcher(streamed, PublicDns, null);
(await a.FetchAsync(ClientId, CancellationToken.None)).Error.Should().Contain("16 KiB");
(await b.FetchAsync(ClientId, CancellationToken.None)).Error.Should().Contain("16 KiB");
}
[Fact]
public async Task A_slow_document_is_given_up_on_after_five_seconds()
{
var transport = new StubTransport(async (_, ct) =>
{
await Task.Delay(TimeSpan.FromSeconds(30), ct);
return Json(Document(ClientId));
});
using var fetcher = new McpClientMetadataFetcher(transport, PublicDns, null);
var watch = Stopwatch.StartNew();
var result = await fetcher.FetchAsync(ClientId, CancellationToken.None);
result.Error.Should().Contain("5 seconds");
watch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(15));
}
[Theory]
[InlineData("""{"client_id":"https://other.example/metadata.json","client_name":"Claude","redirect_uris":["https://claude.ai/api/mcp/auth_callback"]}""", "different client id")]
[InlineData("""{"client_id":"https://client.example/oauth/metadata.json/","client_name":"Claude","redirect_uris":["https://claude.ai/api/mcp/auth_callback"]}""", "different client id")]
[InlineData("""{"client_id":"https://client.example/oauth/metadata.json","redirect_uris":["https://claude.ai/api/mcp/auth_callback"]}""", "name")]
[InlineData("""{"client_id":"https://client.example/oauth/metadata.json","client_name":"Claude","redirect_uris":[]}""", "redirect")]
[InlineData("""{"client_id":"https://client.example/oauth/metadata.json","client_name":"Claude","redirect_uris":["http://claude.ai/callback"]}""", "redirect")]
[InlineData("""{"client_id":"https://client.example/oauth/metadata.json","client_name":"Claude","redirect_uris":["https://claude.ai/api/mcp/auth_callback"],"token_endpoint_auth_method":"client_secret_basic"}""", "client secret")]
[InlineData("""not json""", "JSON")]
[InlineData("""["https://client.example/oauth/metadata.json"]""", "object")]
public async Task A_document_that_does_not_check_out_is_refused(string json, string reason)
{
using var fetcher = new McpClientMetadataFetcher(new StubTransport(_ => Json(json)), PublicDns, null);
var result = await fetcher.FetchAsync(ClientId, CancellationToken.None);
result.Metadata.Should().BeNull();
result.Error.Should().Contain(reason);
}
[Fact]
public async Task A_good_document_is_read_and_kept_for_ten_minutes()
{
var transport = new StubTransport(_ => Json(Document(ClientId)));
var clock = new ManualClock(DateTimeOffset.Parse("2026-11-07T10:00:00Z"));
using var fetcher = new McpClientMetadataFetcher(transport, PublicDns, clock);
var first = await fetcher.FetchAsync(ClientId, CancellationToken.None);
clock.Now += TimeSpan.FromMinutes(9);
var cached = await fetcher.FetchAsync(ClientId, CancellationToken.None);
clock.Now += TimeSpan.FromMinutes(2);
await fetcher.FetchAsync(ClientId, CancellationToken.None);
first.Metadata!.ClientName.Should().Be("Claude");
first.Metadata.ClientHost.Should().Be("client.example");
first.Metadata.RedirectUris.Should().Equal("https://claude.ai/api/mcp/auth_callback", "http://127.0.0.1:33418/callback");
cached.Metadata.Should().Be(first.Metadata);
transport.Requests.Should().HaveCount(2);
}
[Theory]
[InlineData("https://claude.ai/api/mcp/auth_callback", true)]
[InlineData("https://claude.ai/api/mcp/auth_callback/", false)]
[InlineData("https://claude.ai/api/mcp/auth_callback?x=1", false)]
[InlineData("https://evil.example/api/mcp/auth_callback", false)]
[InlineData("http://127.0.0.1:33418/callback", true)]
[InlineData("http://127.0.0.1:50000/callback", true)]
[InlineData("http://127.0.0.1:50000/other", false)]
[InlineData("http://localhost:50000/callback", false)]
[InlineData("https://127.0.0.1:50000/callback", false)]
public void A_redirect_uri_must_be_listed_and_loopback_may_change_port(string redirectUri, bool allowed)
{
var metadata = new McpClientMetadata(ClientId, "Claude",
["https://claude.ai/api/mcp/auth_callback", "http://127.0.0.1:33418/callback"]);
metadata.AllowsRedirect(redirectUri).Should().Be(allowed);
}
// ------------------------------------------------------------------
private static Task<IPAddress[]> PublicDns(string host, CancellationToken ct) =>
Task.FromResult(new[] { IPAddress.Parse("93.184.216.34") });
private static string Document(string clientId) =>
$$"""{"client_id":"{{clientId}}","client_name":"Claude","redirect_uris":["https://claude.ai/api/mcp/auth_callback","http://127.0.0.1:33418/callback"],"token_endpoint_auth_method":"none"}""";
private static HttpResponseMessage Json(string json) =>
new(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
private static HttpResponseMessage Redirect(string location)
{
var response = new HttpResponseMessage(HttpStatusCode.Found);
response.Headers.Location = new Uri(location);
return response;
}
private sealed class StubTransport : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _respond;
public StubTransport(Func<HttpRequestMessage, HttpResponseMessage> respond) =>
_respond = (request, _) => Task.FromResult(respond(request));
public StubTransport(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond) =>
_respond = respond;
public List<Uri> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(request.RequestUri!);
return _respond(request, cancellationToken);
}
}
private sealed class UnseekableStream(byte[] bytes) : MemoryStream(bytes)
{
public override bool CanSeek => false;
public override long Length => throw new NotSupportedException();
}
private sealed class ManualClock(DateTimeOffset now) : TimeProvider
{
public DateTimeOffset Now { get; set; } = now;
public override DateTimeOffset GetUtcNow() => Now;
}
}