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:
@@ -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"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user