aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCosmin Dumitru <cosu@cosu.ro>2026-02-18 21:08:35 +0100
committerCosmin Dumitru <cosu@cosu.ro>2026-02-18 21:08:35 +0100
commit37b50fe13c689e8fb89288da12f79bf7164e1194 (patch)
tree6700515145dc62c913dc7524b3392f396747dc1b
parent06a6c6e16b274e99e00402a21818d4dbb760eca7 (diff)
Fix malformed query string in StreamInfo.ToUrl() causing 500 error via proxies
StreamInfo.ToUrl() generated URLs like `/master.m3u8?&DeviceId=...` (note `?&`) because `?` was appended to the path and all parameters started with `&`. When the first optional parameter (DeviceProfileId) was null, the result was a malformed query string. This is harmless when clients hit Jellyfin directly (ASP.NET Core tolerates `?&`), but when accessed through a reverse proxy that parses and re-serializes the URL (e.g. Home Assistant ingress via aiohttp/yarl), `?&` becomes `?=&` — introducing an empty-key query parameter. ParseStreamOptions then crashes on `param.Key[0]` with IndexOutOfRangeException. Changes: - StreamInfo.ToUrl(): Track query start position and replace the first `&` with `?` after all parameters are appended, producing valid query strings - ParseStreamOptions: Guard against empty query parameter keys - Tests: Remove .Replace("?&", "?") workaround that masked the bug Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--Jellyfin.Api/Helpers/StreamingHelpers.cs2
-rw-r--r--MediaBrowser.Model/Dlna/StreamInfo.cs12
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs6
3 files changed, 12 insertions, 8 deletions
diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs
index 1e984542e..c6823fa80 100644
--- a/Jellyfin.Api/Helpers/StreamingHelpers.cs
+++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs
@@ -268,7 +268,7 @@ public static class StreamingHelpers
Dictionary<string, string?> streamOptions = new Dictionary<string, string?>();
foreach (var param in queryString)
{
- if (char.IsLower(param.Key[0]))
+ if (param.Key.Length > 0 && char.IsLower(param.Key[0]))
{
// This was probably not parsed initially and should be a StreamOptions
// or the generated URL should correctly serialize it
diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs
index 551bee89e..7aad97ce0 100644
--- a/MediaBrowser.Model/Dlna/StreamInfo.cs
+++ b/MediaBrowser.Model/Dlna/StreamInfo.cs
@@ -895,7 +895,7 @@ public class StreamInfo
if (SubProtocol == MediaStreamProtocol.hls)
{
- sb.Append("/master.m3u8?");
+ sb.Append("/master.m3u8");
}
else
{
@@ -906,10 +906,10 @@ public class StreamInfo
sb.Append('.');
sb.Append(Container);
}
-
- sb.Append('?');
}
+ var queryStart = sb.Length;
+
if (!string.IsNullOrEmpty(DeviceProfileId))
{
sb.Append("&DeviceProfileId=");
@@ -1133,6 +1133,12 @@ public class StreamInfo
sb.Append(query);
}
+ // Replace the first '&' with '?' to form a valid query string.
+ if (sb.Length > queryStart)
+ {
+ sb[queryStart] = '?';
+ }
+
return sb.ToString();
}
diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs
index 8dea46806..4b3126fe1 100644
--- a/tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamInfoTests.cs
@@ -216,8 +216,7 @@ public class StreamInfoTests
string legacyUrl = streamInfo.ToUrl_Original(BaseUrl, "123");
- // New version will return and & after the ? due to optional parameters.
- string newUrl = streamInfo.ToUrl(BaseUrl, "123", null).Replace("?&", "?", StringComparison.OrdinalIgnoreCase);
+ string newUrl = streamInfo.ToUrl(BaseUrl, "123", null);
Assert.Equal(legacyUrl, newUrl, ignoreCase: true);
}
@@ -234,8 +233,7 @@ public class StreamInfoTests
FillAllProperties(streamInfo);
string legacyUrl = streamInfo.ToUrl_Original(BaseUrl, "123");
- // New version will return and & after the ? due to optional parameters.
- string newUrl = streamInfo.ToUrl(BaseUrl, "123", null).Replace("?&", "?", StringComparison.OrdinalIgnoreCase);
+ string newUrl = streamInfo.ToUrl(BaseUrl, "123", null);
Assert.Equal(legacyUrl, newUrl, ignoreCase: true);
}