Initial curated itworx-knowledge-hub source from 3b1751d0d9dee551aa6994eee0093704dc334817
This commit is contained in:
commit
ad8ad252a9
825 files changed
+124216
No files matched your search
+195
@@ -0,0 +1,195 @@
|
||||
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><tag></c> into inert escaped text
|
||||
/// (<c>&lt;tag&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 ["", "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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user