fix(iptv): XML-escape advertised base URL in XMLTV guide output (#340 review)
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 3m37s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m30s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m10s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m43s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Independent review found that AdvertisedBaseUrl.TryParse accepts a path
prefix containing '&' (a legal URL-path char kept out of uri.Query), but
GetChannelGuideHandler substituted {RequestBase} raw into pre-built XML
written unescaped — so a configured base like https://host/a&b emitted a
bare '&', malforming the entire XMLTV guide (clients reject the document).

Escape the substituted base with SecurityElement.Escape before the raw
replace, mirroring how {AccessTokenUri} is already pre-escaped (&).
No-op for normal URLs (goldens unchanged); M3U output is untouched (M3U
isn't XML). Adds a regression test asserting '&' → '&' in the guide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 21:08:42 +02:00
co-authored by Claude Opus 4.8
parent 3bc8192d3b
commit 27d01db265
2 changed files with 47 additions and 1 deletions
@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Security;
using System.Text;
using System.Text.RegularExpressions;
using ErsatzTV.Core;
@@ -36,7 +37,11 @@ public partial class GetChannelGuideHandler(
request.Host,
request.BaseUrl);
string requestBase = $"{scheme}://{host}{baseUrl}";
// The cache fragments are pre-built XML written raw (like {AccessTokenUri}, which is already
// emitted as &amp;), so the substituted base must be XML-escaped. A path prefix can legally
// contain '&' (Uri keeps it out of the query), which would otherwise emit a bare '&' and
// malform the whole guide. Normal URLs have no special chars, so this is a no-op for them.
string requestBase = SecurityElement.Escape($"{scheme}://{host}{baseUrl}");
var hiddenChannelNumbers = dbContext.Channels
.Where(c => c.ShowInEpg == false)
.Select(c => c.Number)
@@ -146,6 +146,47 @@ public class ChannelGuideGoldenTests
xml.ShouldNotContain(Host);
}
// A configured base URL whose path prefix contains an XML-special character ('&' is a legal URL
// path char, so it passes AdvertisedBaseUrl validation) must be XML-escaped when substituted into
// the guide fragments — otherwise a bare '&' malforms the whole document. (Reviewer finding, #340.)
[Test]
public async Task Guide_xml_escapes_advertised_base_url()
{
MockFileSystem fileSystem = BuildCacheFileSystem();
var localFileSystem = Substitute.For<ILocalFileSystem>();
localFileSystem
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
.Returns(new[]
{
FragmentPath(fileSystem, "channels.xml"),
FragmentPath(fileSystem, "2.xml")
});
var configElementRepository = Substitute.For<IConfigElementRepository>();
configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.Some("https://tv.example.com/a&b"));
var handler = new GetChannelGuideHandler(
_dbContextFactory,
new RecyclableMemoryStreamManager(),
fileSystem,
localFileSystem,
configElementRepository);
Either<BaseError, ChannelGuide> result = await handler.Handle(
new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null),
CancellationToken.None);
string xml = result.Match(
Right: guide => guide.ToXml(),
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
// The '&' from the base URL must be emitted as '&amp;', never a bare '&'.
xml.ShouldContain("https://tv.example.com/a&amp;b/iptv/logos/news.jpg");
xml.ShouldNotContain("a&b");
}
// --- harness ---
private async Task Verify(string goldenName, GetChannelGuide request)