fix(seo): escape inline JSON-LD, per-release byArtist, soft-404 + env-gated noindex

Escape </>& in JSON-LD body to kill script-breakout; byArtist now uses the release artist; detail-page not-found branches emit noindex; default robots gated to Production via a PersistentState SeoEnvironment bridge.
This commit is contained in:
daniel-c-harvey
2026-06-23 06:10:03 -04:00
parent f3b89ca9d7
commit f976af0f7c
11 changed files with 157 additions and 9 deletions
+75 -3
View File
@@ -26,11 +26,16 @@ public class SeoModelTests
SameAs = ["https://instagram.com/deepdrft.music"],
};
private static ReleaseDto Release(ReleaseMedium medium, string? image = "cover.jpg", string? description = "desc") => new()
private static ReleaseDto Release(
ReleaseMedium medium,
string? image = "cover.jpg",
string? description = "desc",
string title = "Test Release",
string artist = "Aphex Twin") => new()
{
EntryKey = "abc-key",
Title = "Test Release",
Artist = "Deep DRFT",
Title = title,
Artist = artist,
Genre = "House",
Description = description,
ImagePath = image,
@@ -233,4 +238,71 @@ public class SeoModelTests
Assert.That(model.JsonLd, Is.Null);
});
}
// --- byArtist consistency: per-release artist, matching music:musician -------
[TestCase(ReleaseMedium.Cut)]
[TestCase(ReleaseMedium.Session)]
[TestCase(ReleaseMedium.Mix)]
public void ForRelease_ByArtist_IsPerReleaseArtist_NotCollectiveName(ReleaseMedium medium)
{
var model = SeoModel.ForRelease(Options, Release(medium, artist: "Aphex Twin"));
var node = Parse(model.JsonLd);
Assert.Multiple(() =>
{
// byArtist mirrors the OG music:musician value (the release's own artist), not the SiteName.
Assert.That(node.GetProperty("byArtist").GetProperty("name").GetString(), Is.EqualTo("Aphex Twin"));
Assert.That(node.GetProperty("byArtist").GetProperty("name").GetString(), Is.Not.EqualTo(Options.SiteName));
Assert.That(model.Artist, Is.EqualTo("Aphex Twin"));
});
}
[Test]
public void ForRelease_Cut_AlbumTracks_ByArtist_IsPerReleaseArtist()
{
var tracks = new List<TrackDto> { new() { TrackName = "Track", TrackNumber = 1, DurationSeconds = 30 } };
var node = Parse(SeoModel.ForRelease(Options, Release(ReleaseMedium.Cut, artist: "Aphex Twin"), tracks).JsonLd);
Assert.That(node.GetProperty("track")[0].GetProperty("byArtist").GetProperty("name").GetString(),
Is.EqualTo("Aphex Twin"));
}
// --- Critical: inline JSON-LD script-breakout escaping (XSS) -----------------
[Test]
public void ForRelease_TitleWithScriptClose_DoesNotEmitRawAngleBracket()
{
// A CMS-authored title containing </script> / < must not survive raw in the inline script body —
// that is a breakout/XSS vector. The escaped < keeps the JSON parseable while neutralising it.
var release = Release(ReleaseMedium.Cut, title: "Evil</script><script>alert(1)</script>");
var body = SeoModel.ForRelease(Options, release).JsonLd;
Assert.That(body, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(body, Does.Not.Contain("<"), "no raw < may appear in the inline JSON-LD body");
Assert.That(body, Does.Not.Contain(">"), "no raw > may appear in the inline JSON-LD body");
Assert.That(body, Does.Contain("\\u003C"), "< must be emitted as its \\u003C JSON escape");
// The escaped body still parses as JSON and round-trips the original value.
var node = Parse(body);
Assert.That(node.GetProperty("name").GetString(), Is.EqualTo("Evil</script><script>alert(1)</script>"));
});
}
[Test]
public void ForRelease_TitleWithAmpersand_EscapesAmpersand_StillParses()
{
// The title is serialized into the JSON-LD `name`, so an ampersand there exercises the & escape.
var release = Release(ReleaseMedium.Mix, title: "Sound & Vision");
var body = SeoModel.ForRelease(Options, release).JsonLd;
Assert.That(body, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(body, Does.Not.Contain("&"), "no raw & may appear in the inline JSON-LD body");
Assert.That(body, Does.Contain("\\u0026"), "& must be emitted as its \\u0026 JSON escape");
Assert.That(Parse(body).GetProperty("name").GetString(), Is.EqualTo("Sound & Vision"));
});
}
}