Files
EugeneTes 7d4b6ae119 fix(a2a): continuations emit the resumed task, guarded clients bypass proxies
A continued task (input-required -> new message) produced only status updates, so the SDK
answered 'Agent handler did not produce any response events'. The handler now enqueues the
resumed task (caller message and question in history, working) as the first event.
The guarded SocketsHttpHandler sets UseProxy = false: a proxy would make the connection and
skip the ConnectCallback address check.
2026-10-07 13:25:33 +00:00

115 lines
4.8 KiB
C#

using System.Net;
using Microsoft.Extensions.Options;
using Volcanic.AgenticCore.A2A.Server.Push;
namespace Volcanic.AgenticCore.A2A.Server.Tests;
public class PushUrlGuardTests
{
private static PushUrlGuard Guard(bool allowPrivate = false, params string[] resolved) =>
new(Options.Create(new A2AHostOptions { AllowPrivatePushUrls = allowPrivate }),
(_, _) => Task.FromResult(resolved.Select(IPAddress.Parse).ToArray()));
[Theory]
[InlineData("http://hooks.example/x", "must be https")]
[InlineData("https://127.0.0.1/x", "private")]
[InlineData("https://10.1.2.3/x", "private")]
[InlineData("https://100.64.0.7/x", "private")]
[InlineData("not a url", "absolute")]
public async Task Push_PrivateOrPlainUrl_Rejected(string url, string expected) =>
Assert.Contains(expected, await Guard(false, "93.184.216.34").ProblemAsync(url, default));
[Fact]
public async Task Push_HostResolvingToPrivate_Rejected() =>
Assert.Contains("private", await Guard(false, "192.168.1.5").ProblemAsync("https://hooks.example/x", default));
[Fact]
public async Task Push_PublicHttps_Accepted() =>
Assert.Null(await Guard(false, "93.184.216.34").ProblemAsync("https://hooks.example/x", default));
[Fact]
public async Task Push_PrivateAllowed_Accepted() =>
Assert.Null(await Guard(true).ProblemAsync("http://127.0.0.1:5000/x", default));
}
public class PushUrlGuardAddressTests
{
[Theory]
[InlineData("224.0.0.1", true)]
[InlineData("239.255.255.250", true)]
[InlineData("240.0.0.1", true)]
[InlineData("255.255.255.255", true)]
[InlineData("198.18.0.1", true)]
[InlineData("198.19.255.254", true)]
[InlineData("ff02::1", true)]
[InlineData("::1", true)]
[InlineData("fe80::1", true)]
[InlineData("198.20.0.1", false)]
[InlineData("93.184.216.34", false)]
[InlineData("2606:2800:220:1::1", false)]
public void IsPrivate_ClassifiesRanges(string address, bool expected) =>
Assert.Equal(expected, PushUrlGuard.IsPrivate(IPAddress.Parse(address)));
[Fact]
public async Task GuardedHandler_HostResolvingToLoopback_Refused()
{
using var http = new HttpClient(PushUrlGuard.CreateGuardedHandler(() => false,
(_, _) => Task.FromResult(new[] { IPAddress.Loopback })));
var ex = await Assert.ThrowsAsync<HttpRequestException>(() => http.GetAsync("http://hooks.example:1/x"));
Assert.Contains("private", ex.ToString());
}
[Fact]
public void GuardedHandler_DoesNotFollowRedirects() =>
Assert.False(PushUrlGuard.CreateGuardedHandler(() => false).AllowAutoRedirect);
[Fact]
public void GuardedHandler_BypassesProxies() =>
Assert.False(PushUrlGuard.CreateGuardedHandler(() => false).UseProxy);
[Fact]
public async Task GuardedHandler_AllowPrivate_ConnectsToLoopback()
{
using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
var server = Task.Run(async () =>
{
using var client = await listener.AcceptTcpClientAsync();
var stream = client.GetStream();
var buffer = new byte[4096];
var request = "";
while (!request.Contains("\r\n\r\n"))
{
var read = await stream.ReadAsync(buffer);
if (read == 0) break;
request += System.Text.Encoding.ASCII.GetString(buffer, 0, read);
}
await stream.WriteAsync("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"u8.ToArray());
});
using var http = new HttpClient(PushUrlGuard.CreateGuardedHandler(() => true,
(_, _) => Task.FromResult(new[] { IPAddress.Loopback })));
Assert.Equal("ok", await http.GetStringAsync($"http://hooks.example:{port}/x"));
await server;
}
[Theory]
[InlineData("tok\r\nX-Evil: 1", null, "token")]
[InlineData(null, "Bearer x", "scheme")]
[InlineData(null, "Bea\nrer", "scheme")]
public void HeaderProblem_RejectsInjection(string? token, string? scheme, string expected)
{
var config = new global::A2A.PushNotificationConfig { Url = "https://hooks.example/x", Token = token };
if (scheme is not null) config.Authentication = new global::A2A.AuthenticationInfo { Scheme = scheme, Credentials = "c" };
Assert.Contains(expected, PushUrlGuard.HeaderProblem(config));
}
[Fact]
public void HeaderProblem_PlainValues_Accepted() =>
Assert.Null(PushUrlGuard.HeaderProblem(new global::A2A.PushNotificationConfig
{
Url = "https://hooks.example/x", Token = "abc-123",
Authentication = new global::A2A.AuthenticationInfo { Scheme = "Bearer", Credentials = "secret" },
}));
}