Files
ersatztv/ErsatzTV/Program.cs
T
timothyandClaude Opus 4.8 cf834d8b60
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m40s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
security(#283): sniff artwork content type from bytes, remove serve-side ?contentType= reflection
S4 stored-XSS + S9 upload-size DoS from the #197 cold API review.

The artwork path trusted client-supplied content types at both ends: upload
validated only the declared multipart Content-Type (never decoded the bytes),
and serving reflected a client `?contentType=` straight into the response
Content-Type on unauthenticated GET sinks (/iptv/logos, /artwork/watermarks).
Chain: upload <script> bytes as image/png -> GET ...?contentType=text/html
serves them as HTML in-origin. nosniff (#279) does not help because the server
explicitly declares text/html.

- Upload: derive the content type from the bytes via SkiaSharp SKCodec
  (header-only, no decode -> no decompression-bomb path); reject non-images 422.
  New ErsatzTV.Core/Images/ImageContentTypes as the single allow-list source.
  Dropped the untrusted declared Content-Type from the UploadArtwork command.
- Serve: removed the ?contentType= reflection structurally -- dropped ContentType
  from GetCachedImagePath and the [FromQuery] binding on GetImage/GetWatermark;
  the handler always sniffs the file, defaulting application/octet-stream.
  ArtworkContentTypeModel.UrlWithContentType is now the bare path; SPA previews
  no longer append the query.
- Defense-in-depth: channel-logo / watermark {path, contentType} DTOs run through
  ArtworkContentTypeModel.Sanitized(), blanking non-allow-listed types on write.
- S9: Kestrel MaxRequestBodySize from ETV_MAXIMUM_UPLOAD_MB rejects oversized
  bodies during read (controller file.Length check kept as friendly-error backstop).

Both serve sinks are IgnoreApi, so no OpenAPI change. Tests: byte-sniff accept/
reject, Sanitized() allow-list, Location no longer carries ?contentType=.
Docs: api-conventions §4a + decisions.md 2026-07-12.

Refs #283 #197 #66

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:07:55 +02:00

185 lines
7.5 KiB
C#

using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using Destructurama;
using ErsatzTV.Core;
using ErsatzTV.Services.Validators;
using Serilog;
using Serilog.Events;
using Serilog.Sinks.SystemConsole.Themes;
namespace ErsatzTV;
public class Program
{
private static readonly string BasePath;
static Program()
{
string executablePath = Environment.ProcessPath ?? string.Empty;
string executable = Path.GetFileNameWithoutExtension(executablePath);
IConfigurationBuilder builder = new ConfigurationBuilder();
BasePath = Path.GetDirectoryName(
"dotnet".Equals(executable, StringComparison.OrdinalIgnoreCase)
? typeof(Program).Assembly.Location
: executablePath);
Configuration = builder
.SetBasePath(BasePath)
.AddJsonFile("appsettings.json", false, false)
.AddJsonFile(
$"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json",
true)
.AddEnvironmentVariables()
.Build();
LoggingLevelSwitches = new LoggingLevelSwitches();
InMemoryLogService = new InMemoryLogService();
}
private static IConfiguration Configuration { get; }
private static LoggingLevelSwitches LoggingLevelSwitches { get; }
internal static InMemoryLogService InMemoryLogService { get; }
public static async Task<int> Main(string[] args)
{
using var _ = new Mutex(
true,
"Global\\ErsatzTV.Singleton.74360cd8985c4d1fb6bc9e81887206fe",
out bool createdNew);
if (!createdNew)
{
Console.WriteLine("Another instance of ErsatztTV is already running.");
return 1;
}
LoggingLevelSwitches.DefaultLevelSwitch.MinimumLevel = LogEventLevel.Information;
LoggingLevelSwitches.ScanningLevelSwitch.MinimumLevel = LogEventLevel.Information;
LoggingLevelSwitches.SchedulingLevelSwitch.MinimumLevel = LogEventLevel.Information;
LoggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = LogEventLevel.Information;
LoggingLevelSwitches.HttpLevelSwitch.MinimumLevel = LogEventLevel.Information;
LoggerConfiguration loggerConfiguration = new LoggerConfiguration()
.ReadFrom.Configuration(Configuration)
.MinimumLevel.ControlledBy(LoggingLevelSwitches.DefaultLevelSwitch)
// scanning
.MinimumLevel.Override("ErsatzTV.Services.ScannerService", LoggingLevelSwitches.ScanningLevelSwitch)
.MinimumLevel.Override("ErsatzTV.Services.SearchIndexService", LoggingLevelSwitches.ScanningLevelSwitch)
.MinimumLevel.Override("ErsatzTV.Scanner", LoggingLevelSwitches.ScanningLevelSwitch)
// scheduling
.MinimumLevel.Override("ErsatzTV.Core.Scheduling", LoggingLevelSwitches.SchedulingLevelSwitch)
.MinimumLevel.Override(
"ErsatzTV.Application.Subtitles.ExtractEmbeddedSubtitlesHandler",
LoggingLevelSwitches.SchedulingLevelSwitch)
// searching
.MinimumLevel.Override(
"ErsatzTV.Infrastructure.Search.SearchQueryParser",
LoggingLevelSwitches.SearchingLevelSwitch)
// streaming
.MinimumLevel.Override("ErsatzTV.Application.Streaming", LoggingLevelSwitches.StreamingLevelSwitch)
.MinimumLevel.Override("ErsatzTV.Application.Troubleshooting", LoggingLevelSwitches.StreamingLevelSwitch)
.MinimumLevel.Override("ErsatzTV.FFmpeg", LoggingLevelSwitches.StreamingLevelSwitch)
.MinimumLevel.Override("ErsatzTV.Core.FFmpeg", LoggingLevelSwitches.StreamingLevelSwitch)
.MinimumLevel.Override("ErsatzTV.Controllers.IptvController", LoggingLevelSwitches.StreamingLevelSwitch)
.MinimumLevel.Override("ErsatzTV.Controllers.InternalController", LoggingLevelSwitches.StreamingLevelSwitch)
.MinimumLevel.Override(
"ErsatzTV.Controllers.TroubleshootController",
LoggingLevelSwitches.StreamingLevelSwitch)
// http
.MinimumLevel.Override("Serilog.AspNetCore.RequestLoggingMiddleware", LoggingLevelSwitches.HttpLevelSwitch)
.Destructure.UsingAttributes()
.Enrich.FromLogContext()
.WriteTo.Sink(InMemoryLogService.Sink)
.WriteTo.File(
FileSystemLayout.LogFilePath,
rollingInterval: RollingInterval.Day,
formatProvider: CultureInfo.InvariantCulture);
// for performance reasons, restrict windows console to error logs
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !Debugger.IsAttached)
{
loggerConfiguration = loggerConfiguration.WriteTo.Console(
LogEventLevel.Error,
theme: AnsiConsoleTheme.Code,
formatProvider: CultureInfo.InvariantCulture);
}
else
{
loggerConfiguration = loggerConfiguration.WriteTo.Console(
theme: AnsiConsoleTheme.Code,
formatProvider: CultureInfo.InvariantCulture);
// for troubleshooting log category
// outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} <{SourceContext:l}> {NewLine}{Exception}"
}
Log.Logger = loggerConfiguration.CreateLogger();
try
{
Environment.SetEnvironmentVariable("DOTNET_HOSTBUILDER__RELOADCONFIGONCHANGE", "false");
IHost host = CreateHostBuilder(args).Build();
//HibernatingRhinos.Profiler.Appender.EntityFramework.EntityFrameworkProfiler.Initialize();
// run environment validation and exit on failure
var validator = host.Services.GetRequiredService<IEnvironmentValidator>();
if (!await validator.Validate())
{
return 1;
}
await host.RunAsync();
return 0;
}
catch (Exception ex)
{
Log.Fatal(ex, "Host terminated unexpectedly");
return 1;
}
finally
{
await Log.CloseAndFlushAsync();
}
}
private static IHostBuilder CreateHostBuilder(string[] args)
{
Settings.UiPort = SystemEnvironment.UiPort;
Settings.StreamingPort = SystemEnvironment.StreamingPort;
return Host.CreateDefaultBuilder(args)
.ConfigureServices(services => services.AddSingleton(LoggingLevelSwitches))
.ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup<Startup>()
.UseConfiguration(Configuration)
.UseKestrel(options =>
{
options.ListenAnyIP(Settings.UiPort);
if (Settings.StreamingPort != Settings.UiPort)
{
options.ListenAnyIP(Settings.StreamingPort);
}
options.AddServerHeader = false;
// Bound the request body to the configured upload cap so an oversized (e.g.
// multipart artwork) body is rejected as it's read, before it is buffered
// (issue #283 S9 — the controller's file.Length check only fired after binding).
options.Limits.MaxRequestBodySize = (long)SystemEnvironment.MaximumUploadMb * 1024 * 1024;
})
.UseContentRoot(BasePath))
.UseSerilog();
}
}