From 3a6e6359a15d396ec43be3d3079a4c903a90c51d Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Fri, 19 Jun 2026 03:04:50 -0400 Subject: [PATCH 1/3] fix(jellyfin): bound compose-file read + validate /config host path (audit #13) ComposeParser read the docker-compose.yml with no size limit and accepted whatever host path was mounted at /config (which flows into Path.Combine and the sqlite3 cast/crew update sink). It now rejects a compose file larger than 1 MB before reading it, and rejects a /config host path that isn't a plain fully-qualified path (no quotes or control characters) - a relative or Unix-style path that can't resolve to a local jellyfin.db on this host now returns a clear error instead of a silently-wrong path. New ComposeParserTests (none existed). --- .../Jellyfin/Services/ComposeParser.cs | 35 +++++++++ .../Modules/Jellyfin/ComposeParserTests.cs | 78 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 tests/ControlMenu.Tests/Modules/Jellyfin/ComposeParserTests.cs diff --git a/src/ControlMenu/Modules/Jellyfin/Services/ComposeParser.cs b/src/ControlMenu/Modules/Jellyfin/Services/ComposeParser.cs index c727340..ba89dc7 100644 --- a/src/ControlMenu/Modules/Jellyfin/Services/ComposeParser.cs +++ b/src/ControlMenu/Modules/Jellyfin/Services/ComposeParser.cs @@ -8,11 +8,27 @@ public record ComposeParseResult( public static class ComposeParser { + // A docker-compose.yml is a small text file; cap the read so a pathological or hostile + // file can't be slurped whole into memory. + private const long MaxComposeBytes = 1_048_576; // 1 MB + public static ComposeParseResult Parse(string composePath) { if (!File.Exists(composePath)) return new(null, null, null, $"File not found: {composePath}"); + long fileLength; + try + { + fileLength = new FileInfo(composePath).Length; + } + catch (Exception ex) + { + return new(null, null, null, $"Cannot read file: {ex.Message}"); + } + if (fileLength > MaxComposeBytes) + return new(null, null, null, $"Compose file is too large (> 1 MB): {composePath}"); + string[] lines; try { @@ -111,10 +127,29 @@ public static ComposeParseResult Parse(string composePath) if (configHostPath is null) return new(containerName, null, null, "No volume mount to /config found in compose file"); + if (!IsValidHostPath(configHostPath)) + return new(containerName, null, null, + $"Unsupported /config host path (must be a fully-qualified path with no quotes or control characters): {configHostPath}"); + var dbPath = Path.Combine(configHostPath, "data", "jellyfin.db"); return new(containerName, configHostPath, dbPath, null); } + /// + /// The /config host path flows to and the + /// sqlite3 update sink, so reject anything that isn't a plain fully-qualified path: no quotes + /// or control characters, and rooted enough to resolve to a real local jellyfin.db (a relative + /// or Unix-style path can't on this host). + /// + private static bool IsValidHostPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) return false; + if (path.Contains('"') || path.Contains('\'')) return false; + foreach (var c in path) + if (char.IsControl(c)) return false; + return Path.IsPathFullyQualified(path); + } + private static int FindMountSeparator(string mount) { // Find the colon that separates host:container (not the Windows drive letter colon) diff --git a/tests/ControlMenu.Tests/Modules/Jellyfin/ComposeParserTests.cs b/tests/ControlMenu.Tests/Modules/Jellyfin/ComposeParserTests.cs new file mode 100644 index 0000000..22f1b2f --- /dev/null +++ b/tests/ControlMenu.Tests/Modules/Jellyfin/ComposeParserTests.cs @@ -0,0 +1,78 @@ +using ControlMenu.Modules.Jellyfin.Services; + +namespace ControlMenu.Tests.Modules.Jellyfin; + +public class ComposeParserTests : IDisposable +{ + private readonly string _dir; + + public ComposeParserTests() + { + _dir = Path.Combine(Path.GetTempPath(), "cm-compose-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_dir); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { /* best-effort */ } + } + + private string WriteCompose(string content) + { + var path = Path.Combine(_dir, "docker-compose.yml"); + File.WriteAllText(path, content); + return path; + } + + [Fact] + public void Parse_ValidCompose_ExtractsContainerConfigAndDbPath() + { + // Windows-rooted host path (tests run on Windows; CM reads the db locally). + var path = WriteCompose( + "services:\n" + + " jellyfin:\n" + + " container_name: jellyfin\n" + + " volumes:\n" + + " - C:/srv/jellyfin/config:/config\n" + + " - C:/media:/media\n"); + + var result = ComposeParser.Parse(path); + + Assert.Null(result.ErrorMessage); + Assert.Equal("jellyfin", result.ContainerName); + Assert.Equal("C:/srv/jellyfin/config", result.ConfigHostPath); + Assert.Equal(Path.Combine("C:/srv/jellyfin/config", "data", "jellyfin.db"), result.DbPath); + } + + [Fact] + public void Parse_FileOverSizeCap_ReturnsError_WithoutParsing() + { + // > 1 MB; the size guard must reject before reading the file into memory. + var big = "services:\n" + new string('#', 1_100_000) + "\n"; + var path = WriteCompose(big); + + var result = ComposeParser.Parse(path); + + Assert.NotNull(result.ErrorMessage); + Assert.Contains("too large", result.ErrorMessage!); + Assert.Null(result.ConfigHostPath); + } + + [Fact] + public void Parse_RelativeConfigHostPath_ReturnsError() + { + // A non-fully-qualified host path can't be resolved to the local jellyfin.db, and is + // rejected as defense-in-depth before it reaches Path.Combine / the sqlite3 sink. + var path = WriteCompose( + "services:\n" + + " jellyfin:\n" + + " container_name: jellyfin\n" + + " volumes:\n" + + " - ./config:/config\n"); + + var result = ComposeParser.Parse(path); + + Assert.NotNull(result.ErrorMessage); + Assert.Null(result.DbPath); + } +} From 9e36f1265f149b16f38fcfb30675b049708a028b Mon Sep 17 00:00:00 2001 From: Jamie Chapman <104535858+bilbospocketses@users.noreply.github.com> Date: Fri, 19 Jun 2026 03:05:04 -0400 Subject: [PATCH 2/3] fix(cameras): harden camera XML parsing against XXE via a shared SafeXml reader (audit #36) ONVIF SOAP, WS-Discovery, and Hikvision ISAPI responses were parsed with XDocument.Parse - which already prohibits DTDs by default on net10 (so this was not exploitable). All three now route through one shared SafeXml.Parse that explicitly sets DtdProcessing.Prohibit, a null XmlResolver (no external-resource fetches), and a ~1 MB document cap, so the no-XXE guarantee is local + future-proof and a hostile responder can't feed an oversized body. New SafeXmlTests; carries the CHANGELOG entries for #13 and #36. --- CHANGELOG.md | 2 + .../Cameras/Network/HikvisionIsapiClient.cs | 2 +- .../Modules/Cameras/Network/OnvifClient.cs | 2 +- .../Cameras/Network/OnvifDiscoveryClient.cs | 2 +- .../Modules/Cameras/Network/SafeXml.cs | 31 +++++++++++++++ .../Modules/Cameras/Network/SafeXmlTests.cs | 38 +++++++++++++++++++ 6 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 src/ControlMenu/Modules/Cameras/Network/SafeXml.cs create mode 100644 tests/ControlMenu.Tests/Modules/Cameras/Network/SafeXmlTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 22e56f0..32fd3c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Email notifications enforce TLS.** The notification mailer moved off the obsolete `System.Net.Mail.SmtpClient` onto MailKit: port 465 uses implicit TLS and every other port requires STARTTLS, so a misconfigured or downgrade-inducing server fails the send instead of transmitting credentials in cleartext. - **Dependabot auto-merge is gated on the PR's head repository, not just the actor.** The auto-merge workflow additionally requires the pull request's head branch to live in this repository, so a fork PR cannot ride the auto-merge path by presenting the bot as author. - **Traced SVGs are no longer served as navigable same-origin content.** The Tracing tool wrote its generated SVG under `wwwroot/temp` and pointed both the preview `` and the Download Copy link at that `/temp/.svg` URL. An SVG fetched from a same-origin URL is active content — an embedded `