From f976af0f7c6b33a2c29b6bf93e964dc5cae0b2dc Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Tue, 23 Jun 2026 06:10:03 -0400 Subject: [PATCH] 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. --- .../Common/SeoEnvironment.cs | 33 ++++++++ DeepDrftPublic.Client/Common/SeoJsonLd.cs | 21 ++++- DeepDrftPublic.Client/Common/SeoModel.cs | 4 +- DeepDrftPublic.Client/Common/SeoOptions.cs | 5 +- DeepDrftPublic.Client/Controls/SeoHead.razor | 5 +- DeepDrftPublic.Client/Pages/CutDetail.razor | 3 + DeepDrftPublic.Client/Pages/MixDetail.razor | 3 + .../Pages/SessionDetail.razor | 3 + DeepDrftPublic.Client/Startup.cs | 5 ++ DeepDrftPublic/Components/App.razor | 6 ++ DeepDrftTests/SeoModelTests.cs | 78 ++++++++++++++++++- 11 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 DeepDrftPublic.Client/Common/SeoEnvironment.cs diff --git a/DeepDrftPublic.Client/Common/SeoEnvironment.cs b/DeepDrftPublic.Client/Common/SeoEnvironment.cs new file mode 100644 index 0000000..a9e017d --- /dev/null +++ b/DeepDrftPublic.Client/Common/SeoEnvironment.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Components; + +namespace DeepDrftPublic.Client.Common; + +/// +/// Environment-gated robots bridge (Phase 22 remediation §4). The beta/staging site is web-hosted and must +/// not be crawled, so the default robots directive is environment-gated: index,follow only in +/// Production, noindex,nofollow everywhere else. A per-page override +/// still wins — this only sets the default. +/// +/// +/// Crawlers read the server-prerendered HTML, so correctness lives in the server prerender pass — but the +/// value must be identical across the InteractiveAuto double render (AC6), so the WASM pass has to resolve +/// the same flag. The WASM assembly has no IWebHostEnvironment (config comes from the server). This +/// mirrors the DarkMode bridge exactly: a scoped service the server seeds during prerender (from +/// IWebHostEnvironment.IsProduction()) and [PersistentState] rounds to the client, so both +/// passes resolve the identical value. SeoHead injects this rather than an environment dependency, +/// honouring the no-environment-in-the-component constraint. +/// +/// +public class SeoEnvironment +{ + /// + /// True only in Production. Seeded server-side and persisted across the WASM boot. Defaults to + /// false so the fail-safe is "do not index" — a missing bridge never accidentally opens a + /// non-production site to crawlers. + /// + [PersistentState] + public bool IsProduction { get; set; } + + /// The environment-gated default robots directive. Explicit page values override this. + public string DefaultRobots => IsProduction ? "index,follow" : "noindex,nofollow"; +} diff --git a/DeepDrftPublic.Client/Common/SeoJsonLd.cs b/DeepDrftPublic.Client/Common/SeoJsonLd.cs index ffa5b8c..9c256fa 100644 --- a/DeepDrftPublic.Client/Common/SeoJsonLd.cs +++ b/DeepDrftPublic.Client/Common/SeoJsonLd.cs @@ -22,13 +22,30 @@ public static class SeoJsonLd DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, // schema.org keys are PascalCase ("@type", "byArtist", "datePublished"); JsonPropertyName drives // each. Encoder relaxed so the JSON sits inline in HTML without over-escaping apostrophes etc. + // Note: the relaxed encoder leaves <, >, & raw — InlineSafe re-escapes exactly those before the + // body is injected into the / < 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"); + 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")); + }); + } + + [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")); + }); + } }