using System.Net.Http.Json; using ItWorx.KnowledgeHub.Contracts.Knowledge; using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure; namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge; /// Markdown and Mermaid output is sanitized against active content /// (docs/27-acceptance-matrix.md, M2-A03) — a corpus of realistic attack payloads /// rendered through the real HTTP pipeline against a real database, not a unit test /// against the sanitizer in isolation. [Collection(PostgresTestGroup.Name)] public sealed class MarkdownSanitizationTests(PostgresContainerFixture postgres) : OwnerSetupTestBase(postgres) { [Fact] public async Task Preview_renders_unsaved_markdown_with_the_same_sanitization_boundary() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var response = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages/preview", new PreviewMarkdownRequest("# Nieuw document\n\n**Veilig** ")); response.EnsureSuccessStatusCode(); var preview = await response.Content.ReadFromJsonAsync(); Assert.NotNull(preview); Assert.Contains("Nieuw document", preview.RenderedHtml, StringComparison.Ordinal); Assert.Contains("Veilig", preview.RenderedHtml, StringComparison.Ordinal); Assert.DoesNotContain(" /// Each payload pairs with the *unescaped, live* marker that would only appear if /// the exploit actually worked — not a bare keyword. Markdig (raw HTML disabled) /// turns an entire literal <tag> into inert escaped text /// (&lt;tag&gt;); quote characters inside that escaped text are /// *not* further escaped, so a bare-keyword check like "javascript:" would still /// match perfectly safe, non-executing text and produce a false failure. Checking /// for the literal unescaped opening angle bracket (or, for genuine CommonMark /// markdown-syntax links/images, the real emitted attribute) is what actually /// distinguishes "rendered as inert text" from "rendered as live markup". /// public static IEnumerable UnsafePayloads() { yield return ["", "", "click", "", "", "x", "
", "body{background:url('javascript:alert(1)')}", "", "", "x", "/ element for these, so the unsafe scheme must be neutralized by // the sanitizer's AllowedSchemes allowlist, not merely by HTML-escaping. yield return ["[click me](javascript:alert(1))", "href=\"javascript:"]; yield return ["![alt](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)", "src=\"data:text/html"]; } [Theory] [MemberData(nameof(UnsafePayloads))] public async Task Unsafe_markup_never_reaches_the_rendered_output_as_live_markup(string payload, string liveMarker) { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var response = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, $"XSS-test {Guid.NewGuid():N}", "WikiPage", payload, null)); response.EnsureSuccessStatusCode(); var page = await response.Content.ReadFromJsonAsync(); Assert.NotNull(page); Assert.DoesNotContain(liveMarker, page.RenderedHtml, StringComparison.OrdinalIgnoreCase); // Re-fetch independently of the create response, exercising the read path too. var fetched = await client.GetFromJsonAsync($"/api/v1/pages/{page.Id}"); Assert.NotNull(fetched); Assert.DoesNotContain(liveMarker, fetched.RenderedHtml, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task A_mermaid_fenced_block_renders_as_escaped_source_not_raw_script() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); const string markdown = "```mermaid\nflowchart LR\n A --> B\n```"; var response = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Mermaid-test", "WikiPage", markdown, null)); response.EnsureSuccessStatusCode(); var page = await response.Content.ReadFromJsonAsync(); Assert.NotNull(page); // The exact wrapper markup is an implementation detail; what matters for // "strict mode, client-rendered" (docs/11-markdown-and-linking.md) is that the // raw diagram source survives intact for mermaid.js to read, and that nothing // resembling an executable tag was produced from it. Assert.Contains("mermaid", page.RenderedHtml, StringComparison.OrdinalIgnoreCase); Assert.Contains("flowchart LR", page.RenderedHtml, StringComparison.Ordinal); Assert.DoesNotContain("(); Assert.NotNull(page); Assert.Contains("Vet", page.RenderedHtml, StringComparison.Ordinal); Assert.Contains("cursief", page.RenderedHtml, StringComparison.Ordinal); Assert.Contains("href=\"https://example.org\"", page.RenderedHtml, StringComparison.Ordinal); Assert.Contains("rel=\"noopener noreferrer nofollow\"", page.RenderedHtml, StringComparison.Ordinal); Assert.Contains("type=\"checkbox\"", page.RenderedHtml, StringComparison.Ordinal); } public static IEnumerable ManagedMarkdownCases() { yield return [ "\n# Bron\n\nInhoud.\n\n\n\nEigen notitie.\n\n", "Bron", "Eigen notitie." ]; yield return [ "# Bron zonder regeleindeNotitie direct erna", "Bron zonder regeleinde", "Notitie direct erna" ]; yield return [ "\nLege notities\n\n\n\n", "Lege notities", string.Empty ]; } [Theory] [MemberData(nameof(ManagedMarkdownCases))] public async Task Known_import_markers_remain_stored_but_never_render( string markdown, string expectedSource, string expectedNotes) { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); var response = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, $"Marker-test {Guid.NewGuid():N}", "WikiPage", markdown, null)); response.EnsureSuccessStatusCode(); var page = await response.Content.ReadFromJsonAsync(); Assert.NotNull(page); Assert.Equal(markdown, page.Markdown); Assert.Contains("", page.Markdown, StringComparison.Ordinal); Assert.DoesNotContain("managed-source", page.RenderedHtml, StringComparison.Ordinal); Assert.DoesNotContain("user-notes", page.RenderedHtml, StringComparison.Ordinal); Assert.Contains(expectedSource, page.RenderedHtml, StringComparison.Ordinal); if (expectedNotes.Length > 0) { Assert.Contains(expectedNotes, page.RenderedHtml, StringComparison.Ordinal); } } [Fact] public async Task Unknown_html_comments_and_active_markup_stay_inert_while_known_markers_are_hidden() { using var client = Factory.CreateClient(); await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword); const string markdown = "\n\n\n\n"; var response = await SetupHelper.PostWithCsrfAsync( client, "/api/v1/pages", new CreatePageRequest(null, "Marker security test", "WikiPage", markdown, null)); response.EnsureSuccessStatusCode(); var page = await response.Content.ReadFromJsonAsync(); Assert.NotNull(page); Assert.DoesNotContain("managed-source", page.RenderedHtml, StringComparison.Ordinal); Assert.Contains("unknown-control", page.RenderedHtml, StringComparison.Ordinal); Assert.DoesNotContain("