mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
e3875f2c91
* .NET: DevUI: add configurable access controls for the DevUI HTTP surface * .NET: DevUI: address review and fix dotnet format - Restore parameterless AddDevUI overloads for binary compatibility on IServiceCollection and IHostApplicationBuilder. - Keep /meta outside the auth-filtered group so the frontend can discover whether a bearer token is required before prompting for one. Surface the actual requirement via MetaResponse.auth_required. - Invoke DevUIOptions.ConfigureEndpoints before mapping protected endpoints so RouteGroupBuilder conventions (RequireAuthorization, rate limiting) reliably apply. - Treat a null RemoteIpAddress as non-loopback in DevUIAuthFilter; tests now set IPAddress.Loopback explicitly when exercising the loopback path. - Add a DEVUI_AUTH_TOKEN env-var fallback test and a /meta-public test. - Fix dotnet format: add UTF-8 BOM to new files, simplify a cref in DevUIOptions, and drop an unused using in the new test. * .NET: DevUI: add missing authRequired param XML tag * .NET: DevUI tests: set loopback/AllowRemoteAccess for null-RemoteIp default DevUIIntegrationTests use the default TestServer which leaves RemoteIpAddress null. With the new conservative loopback default those tests now hit 403; set AllowRemoteAccess on the option since those tests are not exercising access control. Also add the missing SimulateRemoteIp call in the wrong-bearer test. * .NET: DevUI tests: capture DEVUI_AUTH_TOKEN before parallel tests can see it The env-var test was leaking DEVUI_AUTH_TOKEN into parallel DevUIIntegrationTests, intermittently causing their requests to be rejected as 401. Eagerly resolve the singleton DevUIAuthFilter so its constructor captures the token, then restore the env var before any HTTP requests run.
184 lines
6.2 KiB
C#
184 lines
6.2 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System;
|
|
using System.Net;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Headers;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.TestHost;
|
|
using Microsoft.Extensions.AI;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Moq;
|
|
|
|
namespace Microsoft.Agents.AI.DevUI.UnitTests;
|
|
|
|
public class DevUIAccessControlTests
|
|
{
|
|
private static WebApplicationBuilder NewBuilder()
|
|
{
|
|
var builder = WebApplication.CreateBuilder();
|
|
builder.WebHost.UseTestServer();
|
|
|
|
var mockChatClient = new Mock<IChatClient>();
|
|
var agent = new ChatClientAgent(mockChatClient.Object, "Test", "agent-name");
|
|
builder.Services.AddKeyedSingleton<AIAgent>("agent-name", agent);
|
|
|
|
return builder;
|
|
}
|
|
|
|
private static void SimulateRemoteIp(WebApplication app, IPAddress remoteIp)
|
|
{
|
|
app.Use(async (HttpContext ctx, RequestDelegate next) =>
|
|
{
|
|
ctx.Connection.RemoteIpAddress = remoteIp;
|
|
await next(ctx);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NonLoopbackRequest_ReturnsForbiddenByDefaultAsync()
|
|
{
|
|
var builder = NewBuilder();
|
|
builder.Services.AddDevUI();
|
|
|
|
using var app = builder.Build();
|
|
SimulateRemoteIp(app, IPAddress.Parse("192.0.2.1"));
|
|
app.MapDevUI();
|
|
await app.StartAsync();
|
|
|
|
var response = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
|
|
|
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NonLoopbackRequest_IsAllowedWhenAllowRemoteAccessAsync()
|
|
{
|
|
var builder = NewBuilder();
|
|
builder.Services.AddDevUI(o => o.AllowRemoteAccess = true);
|
|
|
|
using var app = builder.Build();
|
|
SimulateRemoteIp(app, IPAddress.Parse("192.0.2.1"));
|
|
app.MapDevUI();
|
|
await app.StartAsync();
|
|
|
|
var response = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoopbackRequest_WithAuthTokenSet_RequiresBearerHeaderAsync()
|
|
{
|
|
var builder = NewBuilder();
|
|
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
|
|
|
|
using var app = builder.Build();
|
|
SimulateRemoteIp(app, IPAddress.Loopback);
|
|
app.MapDevUI();
|
|
await app.StartAsync();
|
|
|
|
var response = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
|
|
|
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoopbackRequest_WithCorrectBearerToken_SucceedsAsync()
|
|
{
|
|
var builder = NewBuilder();
|
|
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
|
|
|
|
using var app = builder.Build();
|
|
SimulateRemoteIp(app, IPAddress.Loopback);
|
|
app.MapDevUI();
|
|
await app.StartAsync();
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("/v1/entities", UriKind.Relative));
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "secret-token");
|
|
var response = await app.GetTestClient().SendAsync(request);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EnvironmentVariableToken_IsEnforcedWhenAuthTokenNotConfiguredAsync()
|
|
{
|
|
const string EnvVar = "DEVUI_AUTH_TOKEN";
|
|
const string EnvToken = "env-token";
|
|
var previous = Environment.GetEnvironmentVariable(EnvVar);
|
|
Environment.SetEnvironmentVariable(EnvVar, EnvToken);
|
|
|
|
WebApplication? app = null;
|
|
try
|
|
{
|
|
var builder = NewBuilder();
|
|
builder.Services.AddDevUI();
|
|
|
|
app = builder.Build();
|
|
|
|
// Force singleton construction so the env var is captured before we
|
|
// restore it; otherwise tests running in parallel can pick up the
|
|
// leaked DEVUI_AUTH_TOKEN.
|
|
_ = app.Services.GetRequiredService<DevUIAuthFilter>();
|
|
}
|
|
finally
|
|
{
|
|
Environment.SetEnvironmentVariable(EnvVar, previous);
|
|
}
|
|
|
|
await using (app)
|
|
{
|
|
SimulateRemoteIp(app, IPAddress.Loopback);
|
|
app.MapDevUI();
|
|
await app.StartAsync();
|
|
|
|
var missing = await app.GetTestClient().GetAsync(new Uri("/v1/entities", UriKind.Relative));
|
|
Assert.Equal(HttpStatusCode.Unauthorized, missing.StatusCode);
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("/v1/entities", UriKind.Relative));
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", EnvToken);
|
|
var accepted = await app.GetTestClient().SendAsync(request);
|
|
Assert.Equal(HttpStatusCode.OK, accepted.StatusCode);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MetaEndpoint_IsReachableWithoutAuthenticationAsync()
|
|
{
|
|
var builder = NewBuilder();
|
|
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
|
|
|
|
using var app = builder.Build();
|
|
SimulateRemoteIp(app, IPAddress.Loopback);
|
|
app.MapDevUI();
|
|
await app.StartAsync();
|
|
|
|
var response = await app.GetTestClient().GetAsync(new Uri("/meta", UriKind.Relative));
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
Assert.Contains("\"auth_required\":true", body);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoopbackRequest_WithWrongBearerToken_ReturnsUnauthorizedAsync()
|
|
{
|
|
var builder = NewBuilder();
|
|
builder.Services.AddDevUI(o => o.AuthToken = "secret-token");
|
|
|
|
using var app = builder.Build();
|
|
SimulateRemoteIp(app, IPAddress.Loopback);
|
|
app.MapDevUI();
|
|
await app.StartAsync();
|
|
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri("/v1/entities", UriKind.Relative));
|
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "not-the-token");
|
|
var response = await app.GetTestClient().SendAsync(request);
|
|
|
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
}
|
|
}
|