Files
ersatztv/ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs
T
timothy 527332a3ac
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
feat(api): default-deny mutating API writes
Refs #43
2026-06-30 18:49:35 +02:00

123 lines
4.5 KiB
C#

using System.Reflection;
using ErsatzTV;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Filters;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Options;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class ApiControllerSecurityTests
{
private static readonly bool ApiKeyAuthorizationFilterIsGlobal = IsApiKeyAuthorizationFilterRegisteredGlobally();
[Test]
public void Every_Mutating_Api_Action_Should_Be_Globally_Protected_Or_Explicitly_Exempt()
{
Type[] apiControllers =
[
typeof(ChannelController),
typeof(CollectionController),
typeof(FFmpegProfileController),
typeof(LibrariesController),
typeof(MaintenanceController),
typeof(PlayoutController),
typeof(ScannerController),
typeof(ScheduleController),
typeof(ScriptedScheduleController),
typeof(SessionController),
typeof(SmartCollectionController)
];
foreach (Type controllerType in apiControllers)
{
bool controllerSkipsApiKey = controllerType
.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true)
.Any();
foreach (MethodInfo action in controllerType
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
bool isMutating = action
.GetCustomAttributes<HttpMethodAttribute>(inherit: true)
.SelectMany(a => a.HttpMethods)
.Any(m => m is "POST" or "PUT" or "PATCH" or "DELETE");
if (!isMutating)
{
continue;
}
bool actionSkipsApiKey = action
.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true)
.Any();
(controllerSkipsApiKey || actionSkipsApiKey || IsGloballyProtected())
.ShouldBeTrue($"{controllerType.Name}.{action.Name} must be covered by global API write auth or explicitly exempt");
}
}
}
[Test]
public void ScannerController_Should_Be_Only_Api_Key_Exempt_Api_Controller()
{
Type[] exemptControllers = typeof(ScannerController)
.Assembly
.GetTypes()
.Where(t => t.Namespace == typeof(ScannerController).Namespace)
.Where(t => t.GetCustomAttributes<ApiControllerAttribute>(inherit: true).Any())
.Where(t => t.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true).Any())
.ToArray();
exemptControllers.ShouldBe([typeof(ScannerController)]);
}
[Test]
public void Startup_Should_Register_ApiKeyAuthorizationFilter_Globally()
{
ApiKeyAuthorizationFilterIsGlobal.ShouldBeTrue();
}
private static bool IsGloballyProtected() => ApiKeyAuthorizationFilterIsGlobal;
private static bool IsApiKeyAuthorizationFilterRegisteredGlobally()
{
var settings = new Dictionary<string, string?>
{
["provider"] = "sqlite",
["ConnectionStrings:Data"] = "Data Source=:memory:"
};
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(settings)
.Build();
var environment = Substitute.For<IWebHostEnvironment>();
environment.ApplicationName.Returns("ErsatzTV");
environment.EnvironmentName.Returns("Development");
environment.ContentRootPath.Returns(TestContext.CurrentContext.TestDirectory);
environment.WebRootPath.Returns(TestContext.CurrentContext.TestDirectory);
environment.ContentRootFileProvider.Returns(new NullFileProvider());
environment.WebRootFileProvider.Returns(new NullFileProvider());
var services = new ServiceCollection();
new Startup(configuration, environment).ConfigureServices(services);
using ServiceProvider provider = services.BuildServiceProvider();
MvcOptions options = provider.GetRequiredService<IOptions<MvcOptions>>().Value;
return options.Filters
.OfType<ServiceFilterAttribute>()
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
}
}