feat(api): default-deny mutating API writes
feat(api): default-deny mutating API writes Refs #43
This commit was merged in pull request #54.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Filters;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the API-key write-path contract for <see cref="ChannelController" />: every mutating
|
||||
/// action (POST/PUT/PATCH/DELETE) must be covered by <see cref="ApiKeyAuthorizationFilter" />.
|
||||
/// The filter is applied at the controller level, so this also protects any future write endpoint
|
||||
/// added to the controller (regression net for the ResetPlayout bypass).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ChannelControllerSecurityTests
|
||||
{
|
||||
[Test]
|
||||
public void Controller_Should_Apply_ApiKeyAuthorizationFilter()
|
||||
{
|
||||
ServiceFilterAttribute? filter = typeof(ChannelController)
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
filter.ShouldNotBeNull("ChannelController must carry the ApiKeyAuthorizationFilter at the class level");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Every_Mutating_Action_Should_Be_Protected()
|
||||
{
|
||||
MethodInfo[] actions = typeof(ChannelController)
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
|
||||
|
||||
bool controllerHasFilter = typeof(ChannelController)
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
foreach (MethodInfo action in actions)
|
||||
{
|
||||
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 actionHasFilter = action
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
(controllerHasFilter || actionHasFilter)
|
||||
.ShouldBeTrue($"Mutating action {action.Name} is not protected by ApiKeyAuthorizationFilter");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Filters;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class CollectionControllerSecurityTests
|
||||
{
|
||||
[TestCase(typeof(CollectionController))]
|
||||
[TestCase(typeof(LibrariesController))]
|
||||
[TestCase(typeof(MaintenanceController))]
|
||||
[TestCase(typeof(SmartCollectionController))]
|
||||
public void Controller_Should_Apply_ApiKeyAuthorizationFilter(Type controllerType)
|
||||
{
|
||||
ServiceFilterAttribute? filter = controllerType
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
filter.ShouldNotBeNull($"{controllerType.Name} must carry ApiKeyAuthorizationFilter at the class level");
|
||||
}
|
||||
|
||||
[TestCase(typeof(CollectionController))]
|
||||
[TestCase(typeof(LibrariesController))]
|
||||
[TestCase(typeof(MaintenanceController))]
|
||||
[TestCase(typeof(SmartCollectionController))]
|
||||
public void Every_Mutating_Action_Should_Be_Protected(Type controllerType)
|
||||
{
|
||||
MethodInfo[] actions = controllerType
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
|
||||
|
||||
bool controllerHasFilter = controllerType
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
foreach (MethodInfo action in actions)
|
||||
{
|
||||
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 actionHasFilter = action
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
(controllerHasFilter || actionHasFilter)
|
||||
.ShouldBeTrue($"Mutating action {controllerType.Name}.{action.Name} is not protected");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Filters;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -43,16 +42,6 @@ public class FFmpegProfileControllerTests
|
||||
ShouldHaveActionRoute("DELETE", "/api/ffmpeg/profiles/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Apply_ApiKeyAuthorizationFilter()
|
||||
{
|
||||
ServiceFilterAttribute? filter = typeof(FFmpegProfileController)
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
filter.ShouldNotBeNull("FFmpegProfileController must carry ApiKeyAuthorizationFilter at the class level");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Mutations_Should_Use_Request_Dtos_For_Wire_Contract()
|
||||
{
|
||||
|
||||
@@ -6,7 +6,6 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Playouts;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Filters;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -40,16 +39,6 @@ public class PlayoutControllerTests
|
||||
ShouldHaveActionRoute(nameof(PlayoutController.Delete), "DELETE", "/api/playouts/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Apply_ApiKeyAuthorizationFilter()
|
||||
{
|
||||
ServiceFilterAttribute? filter = typeof(PlayoutController)
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
filter.ShouldNotBeNull("PlayoutController must carry ApiKeyAuthorizationFilter at the class level");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Create_Should_Use_Stable_Request_Dto()
|
||||
{
|
||||
|
||||
@@ -6,7 +6,6 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Filters;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -49,16 +48,6 @@ public class ScheduleControllerTests
|
||||
"/api/schedules/{id:int}/items/{itemId:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Apply_ApiKeyAuthorizationFilter()
|
||||
{
|
||||
ServiceFilterAttribute? filter = typeof(ScheduleController)
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
filter.ShouldNotBeNull("ScheduleController must carry ApiKeyAuthorizationFilter at the class level");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Update_Should_Use_Stable_Update_Request_Dto()
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@ using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.SmartCollections;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Filters;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -41,16 +40,6 @@ public class SmartCollectionControllerTests
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.Delete), "DELETE", "/api/smart-collections/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Apply_ApiKeyAuthorizationFilter()
|
||||
{
|
||||
ServiceFilterAttribute? filter = typeof(SmartCollectionController)
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
filter.ShouldNotBeNull("SmartCollectionController must carry ApiKeyAuthorizationFilter at the class level");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_With_Location_And_Body()
|
||||
{
|
||||
|
||||
@@ -14,16 +14,27 @@ namespace ErsatzTV.Tests.Filters;
|
||||
[TestFixture]
|
||||
public class ApiKeyAuthorizationFilterTests
|
||||
{
|
||||
private static AuthorizationFilterContext MakeContext(string method, string? apiKeyHeader)
|
||||
private static AuthorizationFilterContext MakeContext(
|
||||
string method,
|
||||
string? apiKeyHeader,
|
||||
string path = "/api/channels",
|
||||
bool skipApiKeyAuthorization = false)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Method = method;
|
||||
httpContext.Request.Path = path;
|
||||
if (apiKeyHeader is not null)
|
||||
{
|
||||
httpContext.Request.Headers[ApiKeyAuthorizationFilter.HeaderName] = apiKeyHeader;
|
||||
}
|
||||
|
||||
var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
|
||||
var actionDescriptor = new ActionDescriptor();
|
||||
if (skipApiKeyAuthorization)
|
||||
{
|
||||
actionDescriptor.EndpointMetadata = [new SkipApiKeyAuthorizationAttribute()];
|
||||
}
|
||||
|
||||
var actionContext = new ActionContext(httpContext, new RouteData(), actionDescriptor);
|
||||
return new AuthorizationFilterContext(actionContext, new List<IFilterMetadata>());
|
||||
}
|
||||
|
||||
@@ -92,4 +103,25 @@ public class ApiKeyAuthorizationFilterTests
|
||||
MakeFilter(configuredKey: "secret").OnAuthorization(context);
|
||||
context.Result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Allow_Non_Api_Mutating_Request_Even_When_Key_Configured()
|
||||
{
|
||||
AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null, path: "/iptv/channels.m3u");
|
||||
MakeFilter(configuredKey: "secret").OnAuthorization(context);
|
||||
context.Result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Allow_Api_Mutating_Request_When_Endpoint_Skips_Api_Key_Authorization()
|
||||
{
|
||||
AuthorizationFilterContext context = MakeContext(
|
||||
"POST",
|
||||
apiKeyHeader: null,
|
||||
path: "/api/scan/0f8fad5b-d9cb-469f-a165-70867728950e/progress",
|
||||
skipApiKeyAuthorization: true);
|
||||
|
||||
MakeFilter(configuredKey: "secret").OnAuthorization(context);
|
||||
context.Result.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Extensions;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -16,11 +15,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
// Apply the optional API-key control at the controller level so that EVERY mutating action
|
||||
// (including future ones) is covered by default; the filter no-ops on read methods (GET) and
|
||||
// when Api:WriteKey is unset, preserving the open LAN behavior. This is fail-safe: a developer
|
||||
// adding a new write endpoint here cannot accidentally leave it unauthenticated.
|
||||
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
|
||||
public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerChannel, IMediator mediator)
|
||||
{
|
||||
[HttpGet("/api/channels")]
|
||||
|
||||
@@ -3,7 +3,6 @@ using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Extensions;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -11,7 +10,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
|
||||
public class CollectionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/collections")]
|
||||
|
||||
@@ -4,7 +4,6 @@ using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
using ErsatzTV.Extensions;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,7 +11,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
|
||||
public class FFmpegProfileController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/ffmpeg/profiles", Name = "GetFFmpegProfiles")]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -8,7 +7,6 @@ namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[EndpointGroupName("general")]
|
||||
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
|
||||
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator)
|
||||
{
|
||||
[HttpPost("/api/libraries/{id:int}/scan")]
|
||||
|
||||
@@ -2,7 +2,6 @@ using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Maintenance;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -10,7 +9,6 @@ namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[EndpointGroupName("general")]
|
||||
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
|
||||
public class MaintenanceController(IMediator mediator, ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
[HttpGet("/api/maintenance/gc")]
|
||||
|
||||
@@ -4,7 +4,6 @@ using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Playouts;
|
||||
using ErsatzTV.Extensions;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,7 +11,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
|
||||
public class PlayoutController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/playouts/{id:int}", Name = "GetPlayoutById")]
|
||||
|
||||
@@ -2,12 +2,14 @@ using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Filters;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[SkipApiKeyAuthorization]
|
||||
[Route("api/scan/{scanId:guid}")]
|
||||
public class ScannerController(
|
||||
IScannerProxyService scannerProxyService,
|
||||
|
||||
@@ -3,7 +3,6 @@ using ErsatzTV.Application.ProgramSchedules;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Extensions;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -11,7 +10,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
|
||||
public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/schedules")]
|
||||
|
||||
@@ -4,7 +4,6 @@ using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.SmartCollections;
|
||||
using ErsatzTV.Extensions;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -12,7 +11,6 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
|
||||
public class SmartCollectionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/smart-collections")]
|
||||
|
||||
@@ -6,12 +6,12 @@ using Microsoft.Extensions.Primitives;
|
||||
namespace ErsatzTV.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Optional API-key authorization for mutating JSON API endpoints (slice #2a).
|
||||
/// Optional API-key authorization for mutating JSON API endpoints.
|
||||
/// Reads the configured key from <c>Api:WriteKey</c>. When that key is empty the filter is
|
||||
/// a no-op (preserving the current open LAN behavior); when it is set, mutating requests
|
||||
/// (POST/PUT/PATCH/DELETE) must present a matching <c>X-Api-Key</c> header or receive 401.
|
||||
/// This is fully independent of <see cref="JwtHelper" /> and only applies to the actions it
|
||||
/// decorates — it never affects /iptv/* or any read endpoint.
|
||||
/// under <c>/api/*</c> (POST/PUT/PATCH/DELETE) must present a matching <c>X-Api-Key</c>
|
||||
/// header or receive 401. This is fully independent of <see cref="JwtHelper" /> and never
|
||||
/// affects /iptv/* or any read endpoint.
|
||||
/// </summary>
|
||||
public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthorizationFilter
|
||||
{
|
||||
@@ -20,6 +20,11 @@ public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthoriz
|
||||
|
||||
public void OnAuthorization(AuthorizationFilterContext context)
|
||||
{
|
||||
if (ShouldSkipApiKeyAuthorization(context))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string configuredKey = configuration[ConfigurationKey];
|
||||
|
||||
// empty key => API-key auth disabled, endpoint is open
|
||||
@@ -50,4 +55,24 @@ public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthoriz
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldSkipApiKeyAuthorization(AuthorizationFilterContext context)
|
||||
{
|
||||
if (context.Filters.OfType<SkipApiKeyAuthorizationAttribute>().Any()
|
||||
|| context.ActionDescriptor.EndpointMetadata.OfType<SkipApiKeyAuthorizationAttribute>().Any())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!context.HttpContext.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string method = context.HttpContext.Request.Method;
|
||||
return !HttpMethods.IsPost(method)
|
||||
&& !HttpMethods.IsPut(method)
|
||||
&& !HttpMethods.IsPatch(method)
|
||||
&& !HttpMethods.IsDelete(method);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace ErsatzTV.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Marks an internal API endpoint as exempt from global API-key write authorization.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public sealed class SkipApiKeyAuthorizationAttribute : Attribute, IFilterMetadata;
|
||||
@@ -338,6 +338,7 @@ public class Startup
|
||||
options.OutputFormatters.Insert(0, new ChannelGuideOutputFormatter());
|
||||
options.OutputFormatters.Insert(0, new DeviceXmlOutputFormatter());
|
||||
options.OutputFormatters.Insert(0, new HdhrJsonOutputFormatter());
|
||||
options.Filters.AddService<ApiKeyAuthorizationFilter>();
|
||||
})
|
||||
.AddNewtonsoftJson(opt =>
|
||||
{
|
||||
|
||||
+8
-2
@@ -100,11 +100,17 @@ The current `.ToActionResult()` only yields 200/400/404, so we add **richer mapp
|
||||
Reuse handler validation. Port the page-only checks in §2.3 into handlers so the API reaches parity with the Blazor UI. No validation logic in controllers.
|
||||
|
||||
### 3.5 Auth — dedicated API key for writes (decoupled from IPTV)
|
||||
**Mechanism:** a dedicated **API key** for mutations — a new config key (e.g. `Api__WriteKey`) checked by a filter applied **only to POST/PUT/DELETE on `/api/*`**. Enforced only when the key is configured (LAN-open default preserved); reads and all `/iptv/*` stay open.
|
||||
**Mechanism:** a dedicated **API key** for mutations — `Api:WriteKey` / `Api__WriteKey`,
|
||||
checked against the `X-Api-Key` request header by a global MVC filter. When the key is configured,
|
||||
all mutating `/api/*` requests (`POST`/`PUT`/`PATCH`/`DELETE`) require the header; when the key is
|
||||
unset, the LAN-open default is preserved. Reads and all `/iptv/*` routes stay open. The scanner
|
||||
callback controller is the designed exemption because scanner child processes call
|
||||
`/api/scan/{scanId}/...` without `X-Api-Key`; any future exemption must be explicit via
|
||||
`[SkipApiKeyAuthorization]`.
|
||||
|
||||
**Why not reuse the JWT scheme (important):** JWT is gated by a single global toggle, `JwtHelper.IsEnabled` ← `JWT:IssuerSigningKey` (`Startup.cs:166`). That **same toggle also gates the IPTV endpoints** (`/iptv/channels.m3u`, `/iptv/xmltv.xml`, streams — `ConditionalIptvAuthorizeFilter:18`) which **Jellyfin and Dispatcharr consume**. Enabling JWT to protect writes would force token auth onto those media feeds — and JWT tokens **expire in 1 day** (`JwtHelper.cs:27`), unsuitable for a standing tuner URL. A dedicated API key **decouples write-auth from media-consumer auth**: turning it on changes **nothing** for Jellyfin/Dispatcharr.
|
||||
|
||||
**Properties:** long-lived credential (no 1-day churn), fit for MCP / new-UI write clients; net-new but small (one filter + one config key); a standard machine-API pattern.
|
||||
**Properties:** long-lived credential (no 1-day churn), fit for MCP / new-UI write clients; net-new but small (one global filter + one config key); a standard machine-API pattern.
|
||||
|
||||
**Backlog:** tighten CORS for mutation routes if writes are exposed beyond LAN.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user