feat(api): default-deny mutating API writes
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m42s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m55s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m52s

feat(api): default-deny mutating API writes

Refs #43
This commit was merged in pull request #54.
This commit is contained in:
2026-06-30 16:55:31 +00:00
21 changed files with 205 additions and 194 deletions
@@ -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")]
+29 -4
View File
@@ -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;
+1
View File
@@ -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
View File
@@ -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.