From 14372575160a58672a44c9ff52034904b5ace156 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:04:40 -0500 Subject: [PATCH] fix: close hole that allowed management ui over streaming port (#2989) --- CHANGELOG.md | 3 ++ .../GetHlsPlaylistByChannelNumberHandler.cs | 2 +- .../ExtractEmbeddedSubtitlesHandlerBase.cs | 10 ++-- .../Queries/GetSubtitlePathByIdHandler.cs | 6 +-- .../Commands/TroubleshootingHandlerBase.cs | 8 +-- ErsatzTV.Core/FFmpeg/ConcatPlaylist.cs | 4 +- .../FFmpeg/FFmpegLibraryProcessService.cs | 4 +- .../PipelineBuilderBaseTests.cs | 4 +- .../Metadata/LocalStatisticsProvider.cs | 2 +- .../Scheduling/DynamicPlayoutItemService.cs | 8 +-- .../Scheduling/PlayoutItemConverter.cs | 10 ++-- .../ExternalJsonPlayoutItemProvider.cs | 2 +- ErsatzTV/Controllers/InternalController.cs | 11 ++-- ErsatzTV/Startup.cs | 50 +++++++++++++------ 14 files changed, 75 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d7263416..40043b3d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Fixed +- Fix case where specifically-crafted requests could access management UI over streaming port + ## [26.8.0] - 2026-08-20 ### Added - Add `Streaming Engine` dropdown to playback troubleshooter to support troubleshooting Next engine playback diff --git a/ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumberHandler.cs index 1c1b68536..edf93524d 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumberHandler.cs @@ -48,7 +48,7 @@ public class GetHlsPlaylistByChannelNumberHandler : _ => string.Empty }; - string endpoint = "ffmpeg/stream"; + string endpoint = "internal/ffmpeg/stream"; string extension = string.Empty; if (request.Mode is "hls-direct") diff --git a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs index 7f70d6de3..d2810a9e7 100644 --- a/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs +++ b/ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs @@ -307,7 +307,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem foreach (int plexMediaSourceId in maybeId) { - return $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}"; + return $"http://localhost:{Settings.StreamingPort}/internal/media/plex/{plexMediaSourceId}/{pmf.Key}"; } break; @@ -316,11 +316,11 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem return mediaItem switch { JellyfinMovie jellyfinMovie => - $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{jellyfinMovie.ItemId}", + $"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{jellyfinMovie.ItemId}", JellyfinEpisode jellyfinEpisode => - $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{jellyfinEpisode.ItemId}", - EmbyMovie embyMovie => $"http://localhost:{Settings.StreamingPort}/media/emby/{embyMovie.ItemId}", - EmbyEpisode embyEpisode => $"http://localhost:{Settings.StreamingPort}/media/emby/{embyEpisode.ItemId}", + $"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{jellyfinEpisode.ItemId}", + EmbyMovie embyMovie => $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{embyMovie.ItemId}", + EmbyEpisode embyEpisode => $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{embyEpisode.ItemId}", _ => file.Path }; } diff --git a/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs b/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs index b15cd30cd..beebe029e 100644 --- a/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs +++ b/ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs @@ -85,7 +85,7 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory dbContextFa { foreach (string subtitlePath in maybeSubtitle.Map(s => s.Path)) { - return $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{subtitlePath}"; + return $"http://localhost:{Settings.StreamingPort}/internal/media/plex/{plexMediaSourceId}/{subtitlePath}"; } } @@ -126,7 +126,7 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory dbContextFa string extension = Subtitle.ExtensionForCodec(subtitle.Codec); var subtitlePath = $"Videos/{jellyfinItemId}/{jellyfinItemId}/Subtitles/{index}/{index}/Stream.{extension}"; - return $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{subtitlePath}"; + return $"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{subtitlePath}"; } } @@ -166,7 +166,7 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory dbContextFa string extension = Subtitle.ExtensionForCodec(subtitle.Codec); var subtitlePath = $"Videos/{embyItemId}/{subtitle.Path}/Subtitles/{subtitle.StreamIndex}/Stream.{extension}"; - return $"http://localhost:{Settings.StreamingPort}/media/emby/{subtitlePath}"; + return $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{subtitlePath}"; } } diff --git a/ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs b/ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs index 849c874d2..82cf42bc8 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs @@ -115,7 +115,7 @@ public abstract class TroubleshootingHandlerBase( { path = !string.IsNullOrWhiteSpace(remoteStream.Url) ? remoteStream.Url - : $"http://localhost:{Settings.StreamingPort}/ffmpeg/remote-stream/{remoteStream.Id}"; + : $"http://localhost:{Settings.StreamingPort}/internal/ffmpeg/remote-stream/{remoteStream.Id}"; } return path; @@ -136,7 +136,7 @@ public abstract class TroubleshootingHandlerBase( foreach (int plexMediaSourceId in maybeId) { - return $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}"; + return $"http://localhost:{Settings.StreamingPort}/internal/media/plex/{plexMediaSourceId}/{pmf.Key}"; } break; @@ -152,7 +152,7 @@ public abstract class TroubleshootingHandlerBase( foreach (string itemId in jellyfinItemId) { - return $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}"; + return $"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{itemId}"; } // attempt to remotely stream emby @@ -165,7 +165,7 @@ public abstract class TroubleshootingHandlerBase( foreach (string itemId in embyItemId) { - return $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}"; + return $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{itemId}"; } return null; diff --git a/ErsatzTV.Core/FFmpeg/ConcatPlaylist.cs b/ErsatzTV.Core/FFmpeg/ConcatPlaylist.cs index 19d35e969..68d8f33c5 100644 --- a/ErsatzTV.Core/FFmpeg/ConcatPlaylist.cs +++ b/ErsatzTV.Core/FFmpeg/ConcatPlaylist.cs @@ -4,6 +4,6 @@ public record ConcatPlaylist(string Scheme, string Host, string ChannelNumber, s { public override string ToString() => $@"ffconcat version 1.0 -file http://localhost:{Settings.StreamingPort}/ffmpeg/stream/{ChannelNumber}?mode={Mode} -file http://localhost:{Settings.StreamingPort}/ffmpeg/stream/{ChannelNumber}?mode={Mode}"; +file http://localhost:{Settings.StreamingPort}/internal/ffmpeg/stream/{ChannelNumber}?mode={Mode} +file http://localhost:{Settings.StreamingPort}/internal/ffmpeg/stream/{ChannelNumber}?mode={Mode}"; } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 550f295c9..4b20c8cfa 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -180,7 +180,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService { SubtitleKind: SubtitleKind.Embedded, IsImage: false, IsExtracted: true }) { // proxy to avoid dealing with escaping - subtitle.Path = $"http://localhost:{Settings.StreamingPort}/media/subtitle/{subtitle.Id}"; + subtitle.Path = $"http://localhost:{Settings.StreamingPort}/internal/media/subtitle/{subtitle.Id}"; foreach (TimeSpan seek in playbackSettings.StreamSeek) { @@ -986,7 +986,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height); var concatInputFile = new ConcatInputFile( - $"http://localhost:{Settings.StreamingPort}/ffmpeg/concat/{channel.Number}?mode=ts-legacy", + $"http://localhost:{Settings.StreamingPort}/internal/ffmpeg/concat/{channel.Number}?mode=ts-legacy", resolution); IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder( diff --git a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs index 64e485b4e..06f468be9 100644 --- a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs +++ b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs @@ -232,7 +232,7 @@ public class PipelineBuilderBaseTests public void Concat_Test() { var resolution = new FrameSize(1920, 1080); - var concatInputFile = new ConcatInputFile("http://localhost:8080/ffmpeg/concat/1", resolution); + var concatInputFile = new ConcatInputFile("http://localhost:8080/internal/ffmpeg/concat/1", resolution); var builder = new SoftwarePipelineBuilder( new DefaultFFmpegCapabilities(), @@ -253,7 +253,7 @@ public class PipelineBuilderBaseTests string command = PrintCommand(None, None, None, concatInputFile, None, result); command.ShouldBe( - "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -f concat -safe 0 -protocol_whitelist file,http,tcp,https,tcp,tls -probesize 32 -readrate 1.0 -stream_loop -1 -i http://localhost:8080/ffmpeg/concat/1 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -c copy -map_metadata -1 -metadata service_provider=\"ErsatzTV\" -metadata service_name=\"Some Channel\" -f mpegts -mpegts_flags +initial_discontinuity pipe:1"); + "-nostdin -hide_banner -nostats -loglevel error -fflags +genpts+discardcorrupt+igndts -f concat -safe 0 -protocol_whitelist file,http,tcp,https,tcp,tls -probesize 32 -readrate 1.0 -stream_loop -1 -i http://localhost:8080/internal/ffmpeg/concat/1 -muxdelay 0 -muxpreload 0 -movflags +faststart -flags cgop -sc_threshold 0 -c copy -map_metadata -1 -metadata service_provider=\"ErsatzTV\" -metadata service_name=\"Some Channel\" -f mpegts -mpegts_flags +initial_discontinuity pipe:1"); } [Test] diff --git a/ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs b/ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs index 04dcbac4e..6f2a898bc 100644 --- a/ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs +++ b/ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs @@ -682,7 +682,7 @@ public partial class LocalStatisticsProvider : ILocalStatisticsProvider { path = !string.IsNullOrWhiteSpace(remoteStream.Url) ? remoteStream.Url - : $"http://localhost:{Settings.StreamingPort}/ffmpeg/remote-stream/{remoteStream.Id}"; + : $"http://localhost:{Settings.StreamingPort}/internal/ffmpeg/remote-stream/{remoteStream.Id}"; } return Task.FromResult(path); diff --git a/ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItemService.cs b/ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItemService.cs index 5c6b1e696..0ffd69aed 100644 --- a/ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItemService.cs +++ b/ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItemService.cs @@ -237,7 +237,7 @@ public class DynamicPlayoutItemService( { path = !string.IsNullOrWhiteSpace(remoteStream.Url) ? remoteStream.Url - : $"http://localhost:{Settings.StreamingPort}/ffmpeg/remote-stream/{remoteStream.Id}"; + : $"http://localhost:{Settings.StreamingPort}/internal/ffmpeg/remote-stream/{remoteStream.Id}"; } return new PlayoutItemWithPath(playoutItem, path); @@ -260,7 +260,7 @@ public class DynamicPlayoutItemService( { return new PlayoutItemWithPath( playoutItem, - $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}"); + $"http://localhost:{Settings.StreamingPort}/internal/media/plex/{plexMediaSourceId}/{pmf.Key}"); } break; @@ -278,7 +278,7 @@ public class DynamicPlayoutItemService( { return new PlayoutItemWithPath( playoutItem, - $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}"); + $"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{itemId}"); } // attempt to remotely stream emby @@ -293,7 +293,7 @@ public class DynamicPlayoutItemService( { return new PlayoutItemWithPath( playoutItem, - $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}"); + $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{itemId}"); } return new PlayoutItemDoesNotExistOnDisk(path); diff --git a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs index 6bde6f2f7..047c7ca05 100644 --- a/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs +++ b/ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs @@ -245,7 +245,7 @@ public class PlayoutItemConverter( return new Core.Next.Source { SourceType = Core.Next.SourceType.Dynamic, - Uri = $"http://localhost:{Settings.StreamingPort}/media/fallback" + Uri = $"http://localhost:{Settings.StreamingPort}/internal/media/fallback" }; } @@ -320,7 +320,7 @@ public class PlayoutItemConverter( return new Core.Next.Source { SourceType = Core.Next.SourceType.Http, - Uri = $"http://localhost:{Settings.StreamingPort}/media/plex/{mediaSourceId}/{pmf.Key}", + Uri = $"http://localhost:{Settings.StreamingPort}/internal/media/plex/{mediaSourceId}/{pmf.Key}", KeepAlive = false, Reconnect = true }; @@ -338,7 +338,7 @@ public class PlayoutItemConverter( return new Core.Next.Source { SourceType = Core.Next.SourceType.Http, - Uri = $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}", + Uri = $"http://localhost:{Settings.StreamingPort}/internal/media/jellyfin/{itemId}", KeepAlive = false, Reconnect = true }; @@ -357,7 +357,7 @@ public class PlayoutItemConverter( return new Core.Next.Source { SourceType = Core.Next.SourceType.Http, - Uri = $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}", + Uri = $"http://localhost:{Settings.StreamingPort}/internal/media/emby/{itemId}", KeepAlive = false, Reconnect = true }; @@ -730,7 +730,7 @@ public class PlayoutItemConverter( Forced = true, IsExtracted = false, SubtitleKind = SubtitleKind.Generated, - Path = $"http://localhost:{Settings.StreamingPort}/ffmpeg/music-video-credits/{playoutItemId}{seekToMs}", + Path = $"http://localhost:{Settings.StreamingPort}/internal/ffmpeg/music-video-credits/{playoutItemId}{seekToMs}", SDH = false } ]; diff --git a/ErsatzTV.Infrastructure/Streaming/ExternalJsonPlayoutItemProvider.cs b/ErsatzTV.Infrastructure/Streaming/ExternalJsonPlayoutItemProvider.cs index 7e73234c4..01d10598a 100644 --- a/ErsatzTV.Infrastructure/Streaming/ExternalJsonPlayoutItemProvider.cs +++ b/ErsatzTV.Infrastructure/Streaming/ExternalJsonPlayoutItemProvider.cs @@ -225,7 +225,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider return new PlayoutItemWithPath( GetPlayoutItem(startTime, mediaItem, program), - $"http://localhost:{Settings.StreamingPort}/media/plex/{server.Id}/{program.PlexFile}"); + $"http://localhost:{Settings.StreamingPort}/internal/media/plex/{server.Id}/{program.PlexFile}"); } } } diff --git a/ErsatzTV/Controllers/InternalController.cs b/ErsatzTV/Controllers/InternalController.cs index 9840be53f..e3eeb7ca5 100644 --- a/ErsatzTV/Controllers/InternalController.cs +++ b/ErsatzTV/Controllers/InternalController.cs @@ -28,6 +28,7 @@ namespace ErsatzTV.Controllers; [ApiController] [ApiExplorerSettings(IgnoreApi = true)] +[Route("/internal")] public class InternalController : StreamingControllerBase { private readonly ILogger _logger; @@ -134,7 +135,7 @@ public class InternalController : StreamingControllerBase return NotFound(); } - [HttpGet("/media/plex/{plexMediaSourceId:int}/{*path}")] + [HttpGet("media/plex/{plexMediaSourceId:int}/{*path}")] public async Task GetPlexMedia( int plexMediaSourceId, string path, @@ -157,7 +158,7 @@ public class InternalController : StreamingControllerBase #endif } - [HttpGet("/media/jellyfin/{*path}")] + [HttpGet("media/jellyfin/{*path}")] public async Task GetJellyfinMedia(string path, CancellationToken cancellationToken) { Either connectionParameters = @@ -187,7 +188,7 @@ public class InternalController : StreamingControllerBase }); } - [HttpGet("/media/emby/{*path}")] + [HttpGet("media/emby/{*path}")] public async Task GetEmbyMedia(string path, CancellationToken cancellationToken) { Either connectionParameters = @@ -219,7 +220,7 @@ public class InternalController : StreamingControllerBase }); } - [HttpGet("/media/subtitle/{id:int}")] + [HttpGet("media/subtitle/{id:int}")] public async Task GetSubtitle( int id, [FromQuery] long? seekToMs, @@ -299,7 +300,7 @@ public class InternalController : StreamingControllerBase return new NotFoundResult(); } - [HttpGet("/media/fallback")] + [HttpGet("media/fallback")] public async Task GetFallbackPlayoutJson(CancellationToken cancellationToken) { if (!Request.Headers.TryGetValue("x-etv-channel", out StringValues channelNumber) || channelNumber.Count != 1) diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index b7a2b91ae..b263e3437 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO.Abstractions; +using System.Net; using System.Reflection; using System.Runtime.InteropServices; using System.Text; @@ -528,6 +529,30 @@ public class Startup } } + // keep this before `UseForwardedHeaders` + app.Use(async (context, next) => + { + if (IsInternalPath(context.Request.Path)) + { + if (context.Connection.RemoteIpAddress is not { } remote || !IPAddress.IsLoopback(remote)) + { + Log.Warning( + "Blocked internal path {Path} from non-loopback {RemoteIp}", + context.Request.Path, + context.Connection.RemoteIpAddress); + context.Response.StatusCode = 404; + return; + } + } + else if (!IsIptvPath(context.Request.Path) && context.Connection.LocalPort != Settings.UiPort) + { + context.Response.StatusCode = 404; + return; + } + + await next(context); + }); + app.UseCors("AllowAll"); app.UseForwardedHeaders(); @@ -621,21 +646,8 @@ public class Startup app.UseResponseCompression(); - app.Use(async (context, next) => - { - if (!context.Request.Host.Value.StartsWith("localhost", StringComparison.OrdinalIgnoreCase) && - !IsIptvPath(context.Request.Path) && - context.Connection.LocalPort != Settings.UiPort) - { - context.Response.StatusCode = 404; - return; - } - - await next(context); - }); - app.MapWhen( - ctx => !IsIptvPath(ctx.Request.Path), + ctx => !IsIptvPath(ctx.Request.Path) && !IsInternalPath(ctx.Request.Path), blazor => { blazor.UseRouting(); @@ -680,6 +692,14 @@ public class Startup iptv.UseRouting(); iptv.UseEndpoints(endpoints => endpoints.MapControllers()); }); + + app.MapWhen( + ctx => IsInternalPath(ctx.Request.Path), + internalApp => + { + internalApp.UseRouting(); + internalApp.UseEndpoints(endpoints => endpoints.MapControllers()); + }); return; bool IsIptvPath(PathString path) @@ -690,6 +710,8 @@ public class Startup path.StartsWithSegments("/lineup.json") || path.StartsWithSegments("/lineup_status.json"); } + + bool IsInternalPath(PathString path) => path.StartsWithSegments("/internal"); } private static void CustomServices(IServiceCollection services)