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 `