261 lines
9.7 KiB
C#
261 lines
9.7 KiB
C#
using System.Net;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Authorization.Infrastructure;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.HttpOverrides;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Options;
|
|
using Nexus.Api.Controllers;
|
|
using Nexus.Api.Extensions;
|
|
using Nexus.Api.Middleware;
|
|
using Nexus.Api.Services;
|
|
using Xunit;
|
|
|
|
namespace Nexus.Api.Tests;
|
|
|
|
public sealed class SecurityBoundaryTests
|
|
{
|
|
[Fact]
|
|
public void Authorization_UsesAuthenticatedFallbackPolicy()
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["Jwt:Key"] = new string('k', 48),
|
|
["Jwt:Issuer"] = "nexus-test",
|
|
["Jwt:Audience"] = "nexus-test"
|
|
})
|
|
.Build();
|
|
var services = new ServiceCollection();
|
|
|
|
services.AddNexusAuth(configuration);
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var options = provider.GetRequiredService<IOptions<AuthorizationOptions>>().Value;
|
|
Assert.NotNull(options.FallbackPolicy);
|
|
Assert.Contains(
|
|
options.FallbackPolicy!.Requirements,
|
|
requirement => requirement is DenyAnonymousAuthorizationRequirement);
|
|
}
|
|
|
|
[Fact]
|
|
public void DomainControllers_DoNotOptOutOfAuthentication()
|
|
{
|
|
Type[] domainControllers =
|
|
[
|
|
typeof(ActivityController),
|
|
typeof(AgentsController),
|
|
typeof(CalendarController),
|
|
typeof(DocsController),
|
|
typeof(GatewayBridgeController),
|
|
typeof(IncidentsController),
|
|
typeof(MemoryController),
|
|
typeof(RoutingController),
|
|
typeof(TeamController)
|
|
];
|
|
|
|
foreach (var controller in domainControllers)
|
|
{
|
|
Assert.Empty(controller.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
|
|
Assert.DoesNotContain(
|
|
controller.GetMethods(),
|
|
method => method.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true).Length > 0);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void TaskAutomationEndpoints_DoNotOptOutOfAuthentication()
|
|
{
|
|
var board = typeof(TasksController).GetMethod(nameof(TasksController.GetBoard));
|
|
var reset = typeof(TasksController).GetMethod(nameof(TasksController.ResetStale));
|
|
|
|
Assert.NotNull(board);
|
|
Assert.NotNull(reset);
|
|
Assert.Empty(board!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
|
|
Assert.Empty(reset!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
|
|
}
|
|
|
|
[Fact]
|
|
public void AgentCommand_RequiresOwnerRole()
|
|
{
|
|
var command = typeof(AgentsController).GetMethod(nameof(AgentsController.SendCommand));
|
|
Assert.NotNull(command);
|
|
|
|
var authorize = Assert.Single(
|
|
command!.GetCustomAttributes(typeof(AuthorizeAttribute), inherit: true)
|
|
.OfType<AuthorizeAttribute>());
|
|
Assert.Equal("owner", authorize.Roles);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(nameof(AuthController.GetCsrfToken))]
|
|
[InlineData(nameof(AuthController.Login))]
|
|
[InlineData(nameof(AuthController.Refresh))]
|
|
[InlineData(nameof(AuthController.Logout))]
|
|
[InlineData(nameof(AuthController.AdminResetPassword))]
|
|
public void PublicAuthBootstrapEndpoints_AreExplicitlyAnonymous(string methodName)
|
|
{
|
|
var method = typeof(AuthController).GetMethod(methodName);
|
|
Assert.NotNull(method);
|
|
Assert.NotEmpty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(nameof(HealthController.Live))]
|
|
[InlineData(nameof(HealthController.Get))]
|
|
public void PublicHealthEndpoints_AreExplicitlyAnonymous(string methodName)
|
|
{
|
|
var method = typeof(HealthController).GetMethod(methodName);
|
|
Assert.NotNull(method);
|
|
Assert.NotEmpty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(nameof(AuthController.GetMe))]
|
|
[InlineData(nameof(AuthController.UpdateProfile))]
|
|
[InlineData(nameof(AuthController.ChangePassword))]
|
|
public void AccountEndpoints_InheritAuthenticatedFallback(string methodName)
|
|
{
|
|
var method = typeof(AuthController).GetMethod(methodName);
|
|
Assert.NotNull(method);
|
|
Assert.Empty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AgentIdentityHeader_IsRejectedWithoutVerifiedAuthentication()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
var context = TaskWorkflowFixture.CreateHttpContext(agentId: "iris");
|
|
|
|
var resolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(
|
|
context,
|
|
fixture.AgentService,
|
|
fixture.Configuration,
|
|
CancellationToken.None);
|
|
|
|
Assert.Null(resolution.AgentId);
|
|
Assert.True(resolution.HeaderProvided);
|
|
Assert.True(resolution.IsRecognized);
|
|
Assert.False(resolution.CredentialVerified);
|
|
Assert.False(resolution.IdentityHintAuthorized);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AgentIdentityHeader_IsAcceptedAsHintAfterVerifiedServiceKey()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
var context = TaskWorkflowFixture.CreateHttpContext(headers: new Dictionary<string, string>
|
|
{
|
|
["X-Agent-Id"] = "iris",
|
|
["X-Nexus-Api-Key"] = "test-service-key"
|
|
});
|
|
|
|
var resolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(
|
|
context,
|
|
fixture.AgentService,
|
|
fixture.Configuration,
|
|
CancellationToken.None);
|
|
|
|
Assert.Equal("iris", resolution.AgentId);
|
|
Assert.True(resolution.CredentialVerified);
|
|
Assert.True(resolution.IdentityHintAuthorized);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AgentIdentityHeader_CannotEscalateAnOrdinaryJwtUser()
|
|
{
|
|
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
|
var context = TaskWorkflowFixture.CreateHttpContext(
|
|
agentId: "iris",
|
|
user: TaskWorkflowFixture.CreateUser("ordinary-user", "user"));
|
|
|
|
var resolution = await RequestAuthorizationHelper.ResolveAgentHeaderAsync(
|
|
context,
|
|
fixture.AgentService,
|
|
fixture.Configuration,
|
|
CancellationToken.None);
|
|
|
|
Assert.Null(resolution.AgentId);
|
|
Assert.True(resolution.CredentialVerified);
|
|
Assert.False(resolution.IdentityHintAuthorized);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApiKeyMiddleware_AuthenticatesMcpRequests()
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["NexusApiKey"] = "service-secret"
|
|
})
|
|
.Build();
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton<IConfiguration>(configuration);
|
|
using var provider = services.BuildServiceProvider();
|
|
var context = new DefaultHttpContext { RequestServices = provider };
|
|
context.Request.Path = "/mcp";
|
|
context.Request.Headers["X-Nexus-Api-Key"] = "service-secret";
|
|
var nextWasCalled = false;
|
|
var middleware = new ApiKeyMiddleware(nextContext =>
|
|
{
|
|
nextWasCalled = true;
|
|
Assert.True(nextContext.User.Identity?.IsAuthenticated);
|
|
Assert.True(nextContext.User.IsInRole("Service"));
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
await middleware.InvokeAsync(context);
|
|
|
|
Assert.True(nextWasCalled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ApiKeyMiddleware_DoesNotAuthenticateMcpFromAgentHeaderAlone()
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["NexusApiKey"] = "service-secret"
|
|
})
|
|
.Build();
|
|
var services = new ServiceCollection();
|
|
services.AddSingleton<IConfiguration>(configuration);
|
|
using var provider = services.BuildServiceProvider();
|
|
var context = new DefaultHttpContext { RequestServices = provider };
|
|
context.Request.Path = "/mcp";
|
|
context.Request.Headers["X-Agent-Id"] = "iris";
|
|
var middleware = new ApiKeyMiddleware(nextContext =>
|
|
{
|
|
Assert.False(nextContext.User.Identity?.IsAuthenticated);
|
|
return Task.CompletedTask;
|
|
});
|
|
|
|
await middleware.InvokeAsync(context);
|
|
}
|
|
|
|
[Fact]
|
|
public void ForwardedHeaders_TrustOnlyDefaultsAndConfiguredProxyRanges()
|
|
{
|
|
var configuration = new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["ForwardedHeaders:ForwardLimit"] = "2",
|
|
["ForwardedHeaders:KnownProxies:0"] = "10.10.0.12",
|
|
["ForwardedHeaders:KnownNetworks:0"] = "10.20.0.0/24"
|
|
})
|
|
.Build();
|
|
var services = new ServiceCollection();
|
|
services.AddNexusForwardedHeaders(configuration);
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var options = provider.GetRequiredService<IOptions<ForwardedHeadersOptions>>().Value;
|
|
|
|
Assert.Equal(2, options.ForwardLimit);
|
|
Assert.Contains(IPAddress.Parse("10.10.0.12"), options.KnownProxies);
|
|
Assert.Contains(options.KnownIPNetworks, network => network.ToString() == "10.20.0.0/24");
|
|
Assert.DoesNotContain(options.KnownIPNetworks, network => network.ToString() == "0.0.0.0/0");
|
|
}
|
|
}
|