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(BlockController), typeof(ChannelController), typeof(CollectionController), typeof(FFmpegProfileController), typeof(FillerPresetController), typeof(LibrariesController), typeof(LogsController), typeof(MaintenanceController), typeof(MediaItemsController), typeof(PlayoutController), typeof(ResolutionController), typeof(ScannerController), typeof(ScheduleController), typeof(ScriptedScheduleController), typeof(SessionController), typeof(SettingsController), typeof(SmartCollectionController), typeof(TraktController), typeof(TroubleshootController), typeof(WatermarkController) ]; foreach (Type controllerType in apiControllers) { bool controllerSkipsApiKey = controllerType .GetCustomAttributes(inherit: true) .Any(); foreach (MethodInfo action in controllerType .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) { bool isMutating = action .GetCustomAttributes(inherit: true) .SelectMany(a => a.HttpMethods) .Any(m => m is "POST" or "PUT" or "PATCH" or "DELETE"); if (!isMutating) { continue; } bool actionSkipsApiKey = action .GetCustomAttributes(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(inherit: true).Any()) .Where(t => t.GetCustomAttributes(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 { ["provider"] = "sqlite", ["ConnectionStrings:Data"] = "Data Source=:memory:" }; IConfiguration configuration = new ConfigurationBuilder() .AddInMemoryCollection(settings) .Build(); var environment = Substitute.For(); 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>().Value; return options.Filters .OfType() .Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter)); } }