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:
thepraandClaude Opus 5.5 committed 2026-10-01 10:54:17 +02:00
1 parent 611abb5857
commit bf7c88ce71
13 files changed
+239 -4

No files matched your search

@@ -158,7 +158,7 @@ namespace PrivaPub.Federation.Actors
.Match(a => a.ActorURI == actor.Id)
.Modify(a => a.UserName, actor.PreferredUsername)
.Modify(a => a.Name, actor.Name)
.Modify(a => a.Biography, actor.Summary)
.Modify(a => a.Biography, ContentSanitizer.Html(actor.Summary))
.Modify(a => a.Url, actor.Url)
.Modify(a => a.Domain, new Uri(actor.Id).Authority)
.Modify(a => a.InboxURL, actor.Inbox)
+12 -3
View File
@@ -2,6 +2,7 @@ using MongoDB.Entities;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
@@ -211,13 +212,16 @@ namespace PrivaPub.Federation.Inbox
if (await _dbEntities.Posts.Match(p => p.ObjectURI == objectUri).ExecuteAnyAsync(token))
return new(StatusCodes.Status202Accepted);
var html = ContentSanitizer.Html(Value(note, "content"));
var post = new PostEntity
{
ObjectURI = objectUri,
ActorURI = author.ActorURI,
GroupId = group?.Id,
Title = Value(note, "summary") ?? Value(note, "name"),
Text = Value(note, "content"),
Text = html,
ContentHtml = html,
ContentFormat = ContentFormat.Html,
HasContentWarning = note["sensitive"] is JsonValue sensitive && sensitive.TryGetValue<bool>(out var s) && s,
AnsweringToPostId = await LocalPostId(inReplyTo, token) ?? inReplyTo,
IsFederatedCopy = true,
@@ -241,13 +245,16 @@ namespace PrivaPub.Federation.Inbox
var participants = addressed.Where(a => a != ActivityPubRenderer.Public).Append(author.ActorURI).ToList();
var dmGroup = await FindOrCreateDmGroup(participants, Value(note, "context") ?? Value(note, "conversation"), token);
var dmHtml = ContentSanitizer.Html(Value(note, "content"));
var dm = new DmPostEntity
{
ObjectURI = objectUri,
ActorURI = author.ActorURI,
GroupId = dmGroup.ID,
Title = Value(note, "summary"),
Text = Value(note, "content"),
Text = dmHtml,
ContentHtml = dmHtml,
ContentFormat = ContentFormat.Html,
HasContentWarning = note["sensitive"] is JsonValue dmSensitive && dmSensitive.TryGetValue<bool>(out var ds) && ds,
AnsweringToPostId = inReplyTo,
IsFederatedCopy = true,
@@ -296,15 +303,17 @@ namespace PrivaPub.Federation.Inbox
return new(StatusCodes.Status202Accepted);
var objectUri = Id(inner);
var text = Value(inner, "content");
var text = ContentSanitizer.Html(Value(inner, "content"));
await DB.Default.Update<PostEntity>()
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
.Modify(p => p.Text, text)
.Modify(p => p.ContentHtml, text)
.Modify(p => p.UpdateDate, DateTime.UtcNow)
.ExecuteAsync(token);
await DB.Default.Update<DmPostEntity>()
.Match(p => p.ObjectURI == objectUri && p.ActorURI == actor.ActorURI)
.Modify(p => p.Text, text)
.Modify(p => p.ContentHtml, text)
.Modify(p => p.UpdateDate, DateTime.UtcNow)
.ExecuteAsync(token);
return new(StatusCodes.Status202Accepted);
@@ -0,0 +1,80 @@
using AngleSharp.Css.Dom;
using AngleSharp.Dom;
using Ganss.Xss;
using System.Text.RegularExpressions;
namespace PrivaPub.Federation.Objects
{
public static partial class ContentSanitizer
{
static readonly HtmlSanitizer Sanitizer = Build();
public static string Html(string html) =>
string.IsNullOrWhiteSpace(html) ? string.Empty : Sanitizer.Sanitize(html).Trim();
static HtmlSanitizer Build()
{
var sanitizer = new HtmlSanitizer(new HtmlSanitizerOptions
{
AllowedTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"p", "br", "span", "a", "abbr", "del", "s", "pre", "blockquote", "code", "b", "strong", "u", "i", "em",
"sub", "sup", "ul", "ol", "li", "ruby", "rt", "rp", "h1", "h2", "h3", "h4", "h5", "h6"
},
AllowedAttributes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"href", "rel", "class", "translate", "start", "reversed", "value", "title"
},
AllowedCssProperties = new HashSet<string>(),
AllowedAtRules = new HashSet<CssRuleType>(),
AllowedSchemes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"http", "https", "dat", "dweb", "ipfs", "ipns", "ssb", "gopher", "xmpp", "magnet", "gemini"
},
UriAttributes = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "href" }
})
{
KeepChildNodes = true
};
foreach (var allowed in new[] { "mention", "hashtag", "ellipsis", "invisible" })
sanitizer.AllowedClasses.Add(allowed);
sanitizer.RemovingCssClass += (_, e) => e.Cancel = MicroformatClass().IsMatch(e.CssClass);
sanitizer.RemovingTag += (_, e) =>
{
if (e.Tag.LocalName is "script" or "style" or "template" or "iframe" or "object" or "embed" or "noscript" or "svg" or "math")
e.Tag.InnerHtml = string.Empty;
};
sanitizer.PostProcessNode += (_, e) =>
{
if (e.Node is not IElement element)
return;
switch (element.LocalName)
{
case "a":
if (!SchemePrefix().IsMatch(element.GetAttribute("href") ?? string.Empty))
element.RemoveAttribute("href");
element.SetAttribute("rel", "nofollow noopener noreferrer");
element.SetAttribute("target", "_blank");
break;
case "h1" or "h2" or "h3" or "h4" or "h5" or "h6":
var paragraph = e.Document.CreateElement("p");
var strong = e.Document.CreateElement("strong");
while (element.FirstChild != default)
strong.AppendChild(element.FirstChild);
paragraph.AppendChild(strong);
e.ReplacementNodes.Add(paragraph);
break;
}
};
return sanitizer;
}
[GeneratedRegex("^[a-z][a-z0-9+.-]*:", RegexOptions.IgnoreCase)]
private static partial Regex SchemePrefix();
[GeneratedRegex("^(h|p|u|dt|e)-[a-z0-9-]+$")]
private static partial Regex MicroformatClass();
}
}
@@ -0,0 +1,40 @@
using MongoDB.Entities;
using PrivaPub.Federation.Objects;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Post;
using PrivaPub.Models.User;
namespace PrivaPub.Infrastructure.Data.Migrations
{
public class _002_sanitize_stored_content : IMigration
{
public async Task UpgradeAsync()
{
foreach (var post in await DB.Default.Find<Post>().ExecuteAsync())
{
var html = post.IsFederatedCopy ? ContentSanitizer.Html(post.Text) : ActivityPubRenderer.Html(post.Text);
await DB.Default.Update<Post>().MatchID(post.ID)
.Modify(p => p.Text, post.IsFederatedCopy ? html : post.Text)
.Modify(p => p.ContentHtml, html)
.Modify(p => p.ContentFormat, post.IsFederatedCopy ? ContentFormat.Html : ContentFormat.Markdown)
.ExecuteAsync();
}
foreach (var post in await DB.Default.Find<DmPost>().ExecuteAsync())
{
var html = post.IsFederatedCopy ? ContentSanitizer.Html(post.Text) : ActivityPubRenderer.Html(post.Text);
await DB.Default.Update<DmPost>().MatchID(post.ID)
.Modify(p => p.Text, post.IsFederatedCopy ? html : post.Text)
.Modify(p => p.ContentHtml, html)
.Modify(p => p.ContentFormat, post.IsFederatedCopy ? ContentFormat.Html : ContentFormat.Markdown)
.ExecuteAsync();
}
foreach (var avatar in await DB.Default.Find<ForeignAvatar>().ExecuteAsync())
await DB.Default.Update<ForeignAvatar>().MatchID(avatar.ID)
.Modify(a => a.Biography, ContentSanitizer.Html(avatar.Biography))
.ExecuteAsync();
}
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace PrivaPub.Models.Post
{
public enum ContentFormat
{
Markdown,
Html
}
}
+2
View File
@@ -9,6 +9,8 @@ namespace PrivaPub.Models.Post
public string GroupId { get; set; }
public string Title { get; set; }
public string Text { get; set; }
public string ContentHtml { get; set; }
public ContentFormat ContentFormat { get; set; }
public List<PostMedia> Media { get; set; } = new();
public List<float> Location { get; set; } = new();
public bool HasContentWarning { get; set; } = false;
+2
View File
@@ -9,6 +9,8 @@ namespace PrivaPub.Models.Post
public string GroupId { get; set; }
public string Title { get; set; }
public string Text { get; set; }
public string ContentHtml { get; set; }
public ContentFormat ContentFormat { get; set; }
public List<PostMedia> Media { get; set; } = new();
public List<float> Location { get; set; } = new();
public float RangeKm { get; set; } = 5.0f;
+1
View File
@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HtmlSanitizer" Version="9.2.1039" />
<PackageReference Include="MailKit" Version="4.18.0" />
<PackageReference Include="Markdig" Version="1.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
+1
View File
@@ -67,6 +67,7 @@ try
BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard));
var mongoSettings = builder.Configuration.GetSection(nameof(MongoSettings)).Get<MongoSettings>();
await DB.InitAsync(mongoSettings.Database, MongoClientSettings.FromConnectionString(mongoSettings.ConnectionString));
await DB.Default.MigrateAsync<Program>();
}
catch (Exception ex)
{
+7
View File
@@ -6,6 +6,7 @@ using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Post;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Group;
using PrivaPub.Models.Post;
using PrivaPub.Resources;
using PrivaPub.StaticServices;
@@ -79,6 +80,8 @@ namespace PrivaPub.Services
GroupId = group?.Id,
Title = form.Title,
Text = form.Text,
ContentHtml = ActivityPubRenderer.Html(form.Text),
ContentFormat = ContentFormat.Markdown,
HasContentWarning = form.HasContentWarning,
AnsweringToPostId = form.AnsweringToPostId,
ActorURI = author.Uri
@@ -213,6 +216,8 @@ namespace PrivaPub.Services
GroupUserId = author.Id,
GroupId = dmGroup.ID,
Text = form.Text,
ContentHtml = ActivityPubRenderer.Html(form.Text),
ContentFormat = ContentFormat.Markdown,
HasContentWarning = form.HasContentWarning,
ActorURI = author.Uri
};
@@ -345,6 +350,7 @@ namespace PrivaPub.Services
AnsweringToPostId = post.AnsweringToPostId,
Title = post.Title,
Text = post.Text,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning,
IsFederatedCopy = post.IsFederatedCopy,
CreationDate = post.CreationDate
@@ -360,6 +366,7 @@ namespace PrivaPub.Services
AnsweringToPostId = post.AnsweringToPostId,
Title = post.Title,
Text = post.Text,
ContentHtml = post.ContentHtml,
HasContentWarning = post.HasContentWarning,
IsFederatedCopy = post.IsFederatedCopy,
CreationDate = post.CreationDate