Files
itworx-knowledge-hub-Public/tests/backend/ItWorx.KnowledgeHub.IntegrationTests/Knowledge/MarkdownSanitizationTests.cs
T

196 lines
10 KiB
C#

using System.Net.Http.Json;
using ItWorx.KnowledgeHub.Contracts.Knowledge;
using ItWorx.KnowledgeHub.IntegrationTests.Infrastructure;
namespace ItWorx.KnowledgeHub.IntegrationTests.Knowledge;
/// <summary>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.</summary>
[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** <script>alert(1)</script>"));
response.EnsureSuccessStatusCode();
var preview = await response.Content.ReadFromJsonAsync<PreviewMarkdownResponse>();
Assert.NotNull(preview);
Assert.Contains("Nieuw document</h1>", preview.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("<strong>Veilig</strong>", preview.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("<script", preview.RenderedHtml, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// 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 <c>&lt;tag&gt;</c> into inert escaped text
/// (<c>&amp;lt;tag&amp;gt;</c>); 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".
/// </summary>
public static IEnumerable<object[]> UnsafePayloads()
{
yield return ["<script>alert(1)</script>", "<script"];
yield return ["<img src=x onerror=alert(1)>", "<img"];
yield return ["<a href=\"javascript:alert(1)\">click</a>", "<a href=\"javascript:"];
yield return ["<iframe src=\"https://evil.example\"></iframe>", "<iframe"];
yield return ["<svg onload=alert(1)>", "<svg"];
yield return ["<div style=\"background:url(javascript:alert(1))\">x</div>", "<div style"];
yield return ["<img src=\"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==\">", "<img src=\"data:text/html"];
yield return ["<style>body{background:url('javascript:alert(1)')}</style>", "<style"];
yield return ["<object data=\"evil.swf\"></object>", "<object"];
yield return ["<form action=\"https://evil.example\"><input></form>", "<form"];
yield return ["<a href=\"vbscript:alert(1)\">x</a>", "<a href=\"vbscript:"];
// Genuine CommonMark link/image syntax (not raw HTML) — Markdig *does* emit a
// real <a>/<img> 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<PageDetail>();
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<PageDetail>($"/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<PageDetail>();
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("<script", page.RenderedHtml, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Ordinary_safe_formatting_still_renders_correctly()
{
using var client = Factory.CreateClient();
await SetupHelper.LoginAsync(client, OwnerEmail, OwnerPassword);
const string markdown = "# Titel\n\n**Vet** en *cursief* met een [veilige koppeling](https://example.org).\n\n- [ ] taak\n- [x] gedaan\n";
var response = await SetupHelper.PostWithCsrfAsync(
client, "/api/v1/pages", new CreatePageRequest(null, "Veilige opmaak", "WikiPage", markdown, null));
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<PageDetail>();
Assert.NotNull(page);
Assert.Contains("<strong>Vet</strong>", page.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("<em>cursief</em>", 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<object[]> ManagedMarkdownCases()
{
yield return
[
"<!-- managed-source:start -->\n# Bron\n\nInhoud.\n<!-- managed-source:end -->\n\n<!-- user-notes:start -->\nEigen notitie.\n<!-- user-notes:end -->\n",
"Bron",
"Eigen notitie."
];
yield return
[
"<!-- managed-source:start --># Bron zonder regeleinde<!-- managed-source:end -->Notitie direct erna<!-- user-notes:start --><!-- user-notes:end -->",
"Bron zonder regeleinde",
"Notitie direct erna"
];
yield return
[
"<!-- managed-source:start -->\nLege notities\n<!-- managed-source:end -->\n<!-- user-notes:start -->\n\n<!-- user-notes:end -->",
"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<PageDetail>();
Assert.NotNull(page);
Assert.Equal(markdown, page.Markdown);
Assert.Contains("<!-- managed-source:start -->", 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 = "<!-- managed-source:start -->\n<!-- unknown-control -->\n<script>alert(1)</script>\n<img src=x onerror=alert(2)>\n<!-- managed-source:end -->";
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<PageDetail>();
Assert.NotNull(page);
Assert.DoesNotContain("managed-source", page.RenderedHtml, StringComparison.Ordinal);
Assert.Contains("unknown-control", page.RenderedHtml, StringComparison.Ordinal);
Assert.DoesNotContain("<script", page.RenderedHtml, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("<img", page.RenderedHtml, StringComparison.OrdinalIgnoreCase);
}
}