Browse Source

fix: close hole that allowed management ui over streaming port (#2989)

pull/2990/head
Jason Dove 3 weeks ago committed by GitHub
parent
commit
1437257516
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumberHandler.cs
  3. 10
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs
  4. 6
      ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs
  5. 8
      ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs
  6. 4
      ErsatzTV.Core/FFmpeg/ConcatPlaylist.cs
  7. 4
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  8. 4
      ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs
  9. 2
      ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs
  10. 8
      ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItemService.cs
  11. 10
      ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs
  12. 2
      ErsatzTV.Infrastructure/Streaming/ExternalJsonPlayoutItemProvider.cs
  13. 11
      ErsatzTV/Controllers/InternalController.cs
  14. 50
      ErsatzTV/Startup.cs

3
CHANGELOG.md

@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

2
ErsatzTV.Application/Streaming/Queries/GetHlsPlaylistByChannelNumberHandler.cs

@ -48,7 +48,7 @@ public class GetHlsPlaylistByChannelNumberHandler : @@ -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")

10
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs

@ -307,7 +307,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem @@ -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 @@ -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
};
}

6
ErsatzTV.Application/Subtitles/Queries/GetSubtitlePathByIdHandler.cs

@ -85,7 +85,7 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> dbContextFa @@ -85,7 +85,7 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> 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<TvContext> dbContextFa @@ -126,7 +126,7 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> 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<TvContext> dbContextFa @@ -166,7 +166,7 @@ public class GetSubtitlePathByIdHandler(IDbContextFactory<TvContext> 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}";
}
}

8
ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs

@ -115,7 +115,7 @@ public abstract class TroubleshootingHandlerBase( @@ -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( @@ -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( @@ -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( @@ -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;

4
ErsatzTV.Core/FFmpeg/ConcatPlaylist.cs

@ -4,6 +4,6 @@ public record ConcatPlaylist(string Scheme, string Host, string ChannelNumber, s @@ -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}";
}

4
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -180,7 +180,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -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 @@ -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(

4
ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs

@ -232,7 +232,7 @@ public class PipelineBuilderBaseTests @@ -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 @@ -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]

2
ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs

@ -682,7 +682,7 @@ public partial class LocalStatisticsProvider : ILocalStatisticsProvider @@ -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);

8
ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItemService.cs

@ -237,7 +237,7 @@ public class DynamicPlayoutItemService( @@ -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( @@ -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( @@ -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( @@ -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);

10
ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs

@ -245,7 +245,7 @@ public class PlayoutItemConverter( @@ -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( @@ -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( @@ -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( @@ -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( @@ -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
}
];

2
ErsatzTV.Infrastructure/Streaming/ExternalJsonPlayoutItemProvider.cs

@ -225,7 +225,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider @@ -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}");
}
}
}

11
ErsatzTV/Controllers/InternalController.cs

@ -28,6 +28,7 @@ namespace ErsatzTV.Controllers; @@ -28,6 +28,7 @@ namespace ErsatzTV.Controllers;
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[Route("/internal")]
public class InternalController : StreamingControllerBase
{
private readonly ILogger<InternalController> _logger;
@ -134,7 +135,7 @@ public class InternalController : StreamingControllerBase @@ -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<IActionResult> GetPlexMedia(
int plexMediaSourceId,
string path,
@ -157,7 +158,7 @@ public class InternalController : StreamingControllerBase @@ -157,7 +158,7 @@ public class InternalController : StreamingControllerBase
#endif
}
[HttpGet("/media/jellyfin/{*path}")]
[HttpGet("media/jellyfin/{*path}")]
public async Task<IActionResult> GetJellyfinMedia(string path, CancellationToken cancellationToken)
{
Either<BaseError, JellyfinConnectionParametersViewModel> connectionParameters =
@ -187,7 +188,7 @@ public class InternalController : StreamingControllerBase @@ -187,7 +188,7 @@ public class InternalController : StreamingControllerBase
});
}
[HttpGet("/media/emby/{*path}")]
[HttpGet("media/emby/{*path}")]
public async Task<IActionResult> GetEmbyMedia(string path, CancellationToken cancellationToken)
{
Either<BaseError, EmbyConnectionParametersViewModel> connectionParameters =
@ -219,7 +220,7 @@ public class InternalController : StreamingControllerBase @@ -219,7 +220,7 @@ public class InternalController : StreamingControllerBase
});
}
[HttpGet("/media/subtitle/{id:int}")]
[HttpGet("media/subtitle/{id:int}")]
public async Task<IActionResult> GetSubtitle(
int id,
[FromQuery] long? seekToMs,
@ -299,7 +300,7 @@ public class InternalController : StreamingControllerBase @@ -299,7 +300,7 @@ public class InternalController : StreamingControllerBase
return new NotFoundResult();
}
[HttpGet("/media/fallback")]
[HttpGet("media/fallback")]
public async Task<IActionResult> GetFallbackPlayoutJson(CancellationToken cancellationToken)
{
if (!Request.Headers.TryGetValue("x-etv-channel", out StringValues channelNumber) || channelNumber.Count != 1)

50
ErsatzTV/Startup.cs

@ -1,6 +1,7 @@ @@ -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 @@ -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 @@ -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 @@ -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 @@ -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)

Loading…
Cancel
Save