Remote HTML is sanitized before it is stored
S5 of the roadmap. ContentSanitizer wraps HtmlSanitizer with Mastodon's allowlist: the inline and list tags Mastodon keeps, href/rel/class and the list attributes, microformat and mention/hashtag/ellipsis/invisible classes, Mastodon's link schemes, every link rel=nofollow noopener noreferrer, relative links unlinked, headings folded to a bold paragraph, and the contents of script, style, svg, iframe and friends dropped rather than kept as text. Post and DmPost gain ContentHtml (what is shown) and ContentFormat (Markdown for local, Html for remote). Inbound Create and Update, and a remote actor's biography, are sanitized on the way in; local posts store their Markdig rendering. Migration _002 does the same to what is already stored, and migrations now run at startup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
This commit is contained in:
1 parent
611abb5857
commit
bf7c88ce71
13 files changed
+239
-4
No files matched your search
@@ -0,0 +1,51 @@
|
||||
using PrivaPub.Federation.Objects;
|
||||
|
||||
namespace PrivaPub.Tests.Federation
|
||||
{
|
||||
public class ContentSanitizerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("<script>alert(1)</script><p>hi</p>", "<p>hi</p>")]
|
||||
[InlineData("<p onclick=\"alert(1)\">hi</p>", "<p>hi</p>")]
|
||||
[InlineData("<img src=x onerror=alert(1)>", "")]
|
||||
[InlineData("<a href=\"javascript:alert(1)\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
|
||||
[InlineData("<a href=\"data:text/html,<script>alert(1)</script>\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
|
||||
[InlineData("<a href=\"/relative\">x</a>", "<a rel=\"nofollow noopener noreferrer\" target=\"_blank\">x</a>")]
|
||||
[InlineData("<iframe src=\"https://evil.example\"></iframe>text", "text")]
|
||||
[InlineData("<style>body{display:none}</style><p>x</p>", "<p>x</p>")]
|
||||
[InlineData("<svg><script>alert(1)</script></svg>", "")]
|
||||
[InlineData("<p style=\"position:fixed\">x</p>", "<p>x</p>")]
|
||||
[InlineData("<form action=\"https://evil.example\"><input name=p></form>ok", "ok")]
|
||||
[InlineData("<p>a<!-- comment -->b</p>", "<p>ab</p>")]
|
||||
[InlineData("<div><p>kept</p></div>", "<p>kept</p>")]
|
||||
[InlineData("<h2>Title</h2>", "<p><strong>Title</strong></p>")]
|
||||
[InlineData("<span class=\"evil big\">x</span>", "<span>x</span>")]
|
||||
public void Removes_what_is_not_allowed(string input, string expected) =>
|
||||
Assert.Equal(expected, ContentSanitizer.Html(input));
|
||||
|
||||
[Fact]
|
||||
public void Keeps_mastodon_mention_and_hashtag_markup()
|
||||
{
|
||||
var html = "<p><span class=\"h-card\" translate=\"no\"><a href=\"https://m.example/@alice\" class=\"u-url mention\">@<span>alice</span></a></span> "
|
||||
+ "<a href=\"https://m.example/tags/fedi\" class=\"mention hashtag\" rel=\"tag\">#<span>fedi</span></a> "
|
||||
+ "<a href=\"https://example.org/a-long-link\"><span class=\"invisible\">https://</span><span class=\"ellipsis\">example.org/a-long</span></a></p>";
|
||||
|
||||
var sanitized = ContentSanitizer.Html(html);
|
||||
|
||||
Assert.Contains("class=\"h-card\"", sanitized);
|
||||
Assert.Contains("class=\"u-url mention\"", sanitized);
|
||||
Assert.Contains("class=\"mention hashtag\"", sanitized);
|
||||
Assert.Contains("class=\"invisible\"", sanitized);
|
||||
Assert.Contains("class=\"ellipsis\"", sanitized);
|
||||
Assert.Contains("href=\"https://m.example/@alice\"", sanitized);
|
||||
Assert.DoesNotContain("rel=\"tag\"", sanitized);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void Empty_input_is_empty(string input) =>
|
||||
Assert.Equal(string.Empty, ContentSanitizer.Html(input));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Infrastructure.Data.Migrations;
|
||||
using PrivaPub.Models.Post;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
namespace PrivaPub.Tests.Infrastructure
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public class MigrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Stored_remote_content_is_sanitized_and_local_content_rendered()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var remote = new Post { IsFederatedCopy = true, Text = "<p>hi<script>alert(1)</script></p>", ObjectURI = $"https://r.example/{Guid.NewGuid():N}" };
|
||||
var local = new Post { Text = "**bold** <b>raw</b>", ObjectURI = $"https://privapub.test/{Guid.NewGuid():N}" };
|
||||
await DB.Default.SaveAsync(new[] { remote, local }, token);
|
||||
|
||||
await new _002_sanitize_stored_content().UpgradeAsync();
|
||||
|
||||
var migratedRemote = await DB.Default.Find<Post>().OneAsync(remote.ID, token);
|
||||
var migratedLocal = await DB.Default.Find<Post>().OneAsync(local.ID, token);
|
||||
Assert.Equal("<p>hi</p>", migratedRemote.Text);
|
||||
Assert.Equal("<p>hi</p>", migratedRemote.ContentHtml);
|
||||
Assert.Equal(ContentFormat.Html, migratedRemote.ContentFormat);
|
||||
Assert.Equal("**bold** <b>raw</b>", migratedLocal.Text);
|
||||
Assert.Equal("<p><strong>bold</strong> <b>raw</b></p>", migratedLocal.ContentHtml);
|
||||
Assert.Equal(ContentFormat.Markdown, migratedLocal.ContentFormat);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user