Files
nexus/backend-tests/SecurityBoundaryTests.cs
T
AzuTear cd8c78d165
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Backend integration (PostgreSQL/Toxiproxy) (push) Failing after 1m0s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m49s
CI - Build & Test / Security Check (push) Successful in 7s
CI - Build & Test / Deploy Nexus (push) Has been skipped
feat(stability): unify readiness and recovery
2026-08-01 01:21:33 +02:00

290 lines
11 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.Security;
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.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.Ready))]
[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 void CookieBackedAuth_AllowsSameOriginBrowserRequests()
{
var context = new DefaultHttpContext();
context.Request.Scheme = "https";
context.Request.Host = new HostString("nexus.noveria.net");
context.Request.Headers.Origin = "https://nexus.noveria.net";
context.Request.Headers["Sec-Fetch-Site"] = "same-origin";
Assert.True(BrowserRequestOriginGuard.IsAllowed(context.Request));
}
[Theory]
[InlineData("https://attacker.invalid", "cross-site")]
[InlineData("https://attacker.invalid", "")]
[InlineData("not-an-origin", "same-origin")]
public void CookieBackedAuth_RejectsExplicitCrossSiteRequests(string origin, string fetchSite)
{
var context = new DefaultHttpContext();
context.Request.Scheme = "https";
context.Request.Host = new HostString("nexus.noveria.net");
context.Request.Headers.Origin = origin;
if (fetchSite.Length > 0)
context.Request.Headers["Sec-Fetch-Site"] = fetchSite;
Assert.False(BrowserRequestOriginGuard.IsAllowed(context.Request));
}
[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");
}
}