diff --git a/Models/Dtos/Editor/EditorAreaRequestParser.cs b/Models/Dtos/Editor/EditorAreaRequestParser.cs
index f152f1c2..293ccbca 100644
--- a/Models/Dtos/Editor/EditorAreaRequestParser.cs
+++ b/Models/Dtos/Editor/EditorAreaRequestParser.cs
@@ -11,10 +11,6 @@ internal static class EditorAreaRequestParser
{
private static readonly string[] SaveServerOwnedFields = { "id", "tripId", "displayOrder", "capabilities" };
private static readonly Regex FillHexRegex = new("^#[0-9a-fA-F]{6}$", RegexOptions.Compiled);
- private static readonly Regex DataImageSourceRegex = new(
- @"
]*?\bsrc\s*=\s*[""']?\s*data:image/",
- RegexOptions.Compiled | RegexOptions.IgnoreCase);
-
///
/// Attempts to parse a complete-draft area create request.
///
@@ -46,7 +42,7 @@ public static bool TryParseCreate(JsonElement request, out EditorAreaSaveRequest
update = new EditorAreaSaveRequest(
string.IsNullOrWhiteSpace(name) ? "Area" : name!.Trim(),
- notesHtml,
+ EditorRichNotesRequestHtml.NormalizeForPersistence(notesHtml),
string.IsNullOrWhiteSpace(fillHex) ? "#ff6600" : fillHex!.Trim().ToLowerInvariant(),
geometry!);
return true;
@@ -78,7 +74,7 @@ public static bool TryParseUpdate(JsonElement request, out EditorAreaSaveRequest
return false;
}
- update = new EditorAreaSaveRequest(name!.Trim(), notesHtml, fillHex!.Trim().ToLowerInvariant(), geometry!);
+ update = new EditorAreaSaveRequest(name!.Trim(), EditorRichNotesRequestHtml.NormalizeForPersistence(notesHtml), fillHex!.Trim().ToLowerInvariant(), geometry!);
return true;
}
@@ -350,7 +346,7 @@ private static void ValidateFillHex(string? fillHex, Dictionary errors)
{
- if (!string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml))
+ if (EditorRichNotesRequestHtml.ContainsDataImageSource(notesHtml))
{
errors["notesHtml"] = new[] { "Notes cannot contain data image sources." };
}
diff --git a/Models/Dtos/Editor/EditorPlaceRequestParser.cs b/Models/Dtos/Editor/EditorPlaceRequestParser.cs
index 9146c08b..baacc3a8 100644
--- a/Models/Dtos/Editor/EditorPlaceRequestParser.cs
+++ b/Models/Dtos/Editor/EditorPlaceRequestParser.cs
@@ -1,5 +1,4 @@
using System.Text.Json;
-using System.Text.RegularExpressions;
namespace Wayfarer.Models.Dtos.Editor;
@@ -17,10 +16,6 @@ internal static class EditorPlaceRequestParser
"capabilities"
};
- private static readonly Regex DataImageSourceRegex = new(
- @"
]*?\bsrc\s*=\s*[""']?\s*data:image/",
- RegexOptions.Compiled | RegexOptions.IgnoreCase);
-
///
/// Attempts to parse a complete-draft place create request.
///
@@ -51,7 +46,7 @@ public static bool TryParseCreate(
update = new EditorPlaceCreateRequest(
fields.Name!,
- fields.NotesHtml,
+ EditorRichNotesRequestHtml.NormalizeForPersistence(fields.NotesHtml),
fields.Address,
fields.Location,
fields.IconName!,
@@ -90,7 +85,7 @@ public static bool TryParseUpdate(
update = new EditorPlaceUpdateRequest(
regionId!.Value,
fields.Name!,
- fields.NotesHtml,
+ EditorRichNotesRequestHtml.NormalizeForPersistence(fields.NotesHtml),
fields.Address,
fields.Location,
fields.IconName!,
@@ -337,7 +332,7 @@ private static void RejectPathOwnedField(JsonElement request, string field, Dict
}
private static bool ContainsDataImageSource(string? notesHtml) =>
- !string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml);
+ EditorRichNotesRequestHtml.ContainsDataImageSource(notesHtml);
private sealed record PlaceSaveFields(
string? Name,
diff --git a/Models/Dtos/Editor/EditorRegionRequestParser.cs b/Models/Dtos/Editor/EditorRegionRequestParser.cs
index b30d5361..f24d2ed8 100644
--- a/Models/Dtos/Editor/EditorRegionRequestParser.cs
+++ b/Models/Dtos/Editor/EditorRegionRequestParser.cs
@@ -1,5 +1,4 @@
using System.Text.Json;
-using System.Text.RegularExpressions;
namespace Wayfarer.Models.Dtos.Editor;
@@ -20,10 +19,6 @@ internal static class EditorRegionRequestParser
"capabilities"
};
- private static readonly Regex DataImageSourceRegex = new(
- @"
]*?\bsrc\s*=\s*[""']?\s*data:image/",
- RegexOptions.Compiled | RegexOptions.IgnoreCase);
-
///
/// Attempts to parse a complete-draft region save request.
///
@@ -57,7 +52,7 @@ public static bool TryParseSave(
return false;
}
- update = new EditorRegionSaveRequest(name!, notesHtml, coverImage, center);
+ update = new EditorRegionSaveRequest(name!, EditorRichNotesRequestHtml.NormalizeForPersistence(notesHtml), coverImage, center);
return true;
}
@@ -276,7 +271,7 @@ private static void ValidateCoverImage(string? rawUrl, Dictionary errors)
{
- if (!string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml))
+ if (EditorRichNotesRequestHtml.ContainsDataImageSource(notesHtml))
{
errors["notesHtml"] = new[] { "Notes images must use external image URLs, not data:image sources." };
}
diff --git a/Models/Dtos/Editor/EditorRichNotesRequestHtml.cs b/Models/Dtos/Editor/EditorRichNotesRequestHtml.cs
new file mode 100644
index 00000000..cdf8d86e
--- /dev/null
+++ b/Models/Dtos/Editor/EditorRichNotesRequestHtml.cs
@@ -0,0 +1,230 @@
+using System.Net;
+using System.Text.RegularExpressions;
+using AngleSharp.Dom;
+using AngleSharp.Html.Parser;
+using Microsoft.AspNetCore.WebUtilities;
+
+namespace Wayfarer.Models.Dtos.Editor;
+
+///
+/// Normalizes Trip Editor rich-notes request HTML before editor mutations persist it.
+///
+internal static class EditorRichNotesRequestHtml
+{
+ private static readonly Regex DataImageSourceRegex = new(
+ @"
]*?\bsrc\s*=\s*[""']?\s*data:image/",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ private static readonly Regex ImageSourceRegex = new(
+ @"(?
]*?\bsrc\s*=\s*[""'])(?[^""']+)(?[""'])",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ private static readonly HtmlParser Parser = new();
+ private static readonly HashSet AllowedTags = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "a", "blockquote", "br", "em", "h1", "h2", "h3", "h4", "h5", "h6", "img", "li", "ol", "p", "span", "strong", "u", "ul"
+ };
+
+ private static readonly HashSet RemovedTags = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "base", "button", "embed", "form", "iframe", "input", "link", "meta", "object", "option", "script", "select", "style", "textarea"
+ };
+
+ private static readonly HashSet AllowedAlignmentClasses = new(StringComparer.Ordinal)
+ {
+ "ql-align-center", "ql-align-right"
+ };
+
+ private static readonly HashSet AllowedFontClasses = new(StringComparer.Ordinal)
+ {
+ "ql-font-monospace", "ql-font-serif"
+ };
+
+ private static readonly HashSet QuillBlockTags = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "li", "p"
+ };
+
+ private static readonly HashSet AllowedListKinds = new(StringComparer.Ordinal)
+ {
+ "bullet", "ordered"
+ };
+
+ ///
+ /// Returns true when request HTML contains a direct embedded data image source.
+ ///
+ public static bool ContainsDataImageSource(string? notesHtml) =>
+ !string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml);
+
+ ///
+ /// Canonicalizes and sanitizes rich-notes HTML accepted by Trip Editor mutation requests.
+ ///
+ public static string? NormalizeForPersistence(string? notesHtml)
+ {
+ if (string.IsNullOrWhiteSpace(notesHtml))
+ {
+ return string.Empty;
+ }
+
+ var canonicalImages = ImageSourceRegex.Replace(notesHtml.Trim(), match =>
+ {
+ var source = CanonicalImageSource(match.Groups["url"].Value);
+ return $"{match.Groups["prefix"].Value}{WebUtility.HtmlEncode(source)}{match.Groups["suffix"].Value}";
+ });
+
+ var document = Parser.ParseDocument(canonicalImages);
+ var body = document.Body;
+ if (body == null)
+ {
+ return string.Empty;
+ }
+
+ foreach (var element in body.QuerySelectorAll("span.ql-ui").ToArray())
+ {
+ element.Remove();
+ }
+
+ foreach (var element in body.QuerySelectorAll("*").Reverse().ToArray())
+ {
+ NormalizeElement(element);
+ }
+
+ RemoveTrailingBlankParagraphs(body);
+ var html = body.InnerHtml.Trim();
+ return string.Equals(html, "
", StringComparison.OrdinalIgnoreCase)
+ ? string.Empty
+ : html;
+ }
+
+ private static void NormalizeElement(IElement element)
+ {
+ if (RemovedTags.Contains(element.TagName))
+ {
+ element.Remove();
+ return;
+ }
+
+ if (!AllowedTags.Contains(element.TagName))
+ {
+ element.Replace(element.ChildNodes.ToArray());
+ return;
+ }
+
+ foreach (var attribute in element.Attributes.ToArray())
+ {
+ if (!IsAllowedAttribute(element, attribute))
+ {
+ element.RemoveAttribute(attribute.Name);
+ }
+ }
+
+ if (string.Equals(element.TagName, "img", StringComparison.OrdinalIgnoreCase))
+ {
+ NormalizeImage(element);
+ }
+ }
+
+ private static bool IsAllowedAttribute(IElement element, IAttr attribute)
+ {
+ var name = attribute.Name.ToLowerInvariant();
+ if (name == "class")
+ {
+ return NormalizeClassAttribute(element);
+ }
+
+ if (name == "href" && string.Equals(element.TagName, "a", StringComparison.OrdinalIgnoreCase))
+ {
+ return IsAllowedLink(attribute.Value);
+ }
+
+ if (name == "src" && string.Equals(element.TagName, "img", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return name == "data-list"
+ && string.Equals(element.TagName, "li", StringComparison.OrdinalIgnoreCase)
+ && AllowedListKinds.Contains(attribute.Value);
+ }
+
+ private static bool NormalizeClassAttribute(IElement element)
+ {
+ var allowed = element.ClassList.Where(className => IsAllowedClass(element, className)).ToArray();
+ if (allowed.Length == 0)
+ {
+ return false;
+ }
+
+ element.SetAttribute("class", string.Join(" ", allowed));
+ return true;
+ }
+
+ private static bool IsAllowedClass(IElement element, string className) =>
+ (string.Equals(element.TagName, "span", StringComparison.OrdinalIgnoreCase) && AllowedFontClasses.Contains(className))
+ || (QuillBlockTags.Contains(element.TagName) && AllowedAlignmentClasses.Contains(className));
+
+ private static void NormalizeImage(IElement element)
+ {
+ var source = CanonicalImageSource(element.GetAttribute("src") ?? string.Empty);
+ if (!IsAllowedAbsoluteHttpUrl(source))
+ {
+ element.Remove();
+ return;
+ }
+
+ element.SetAttribute("src", source);
+ }
+
+ private static bool IsAllowedLink(string value)
+ {
+ var compact = CompactUrlScheme(value);
+ return !compact.StartsWith("javascript:", StringComparison.Ordinal)
+ && !compact.StartsWith("data:", StringComparison.Ordinal)
+ && !compact.StartsWith("vbscript:", StringComparison.Ordinal);
+ }
+
+ private static bool IsAllowedAbsoluteHttpUrl(string value) =>
+ Uri.TryCreate(StripUrlBoundaryControls(value), UriKind.Absolute, out var uri)
+ && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
+
+ private static void RemoveTrailingBlankParagraphs(IElement body)
+ {
+ while (body.LastElementChild != null
+ && string.Equals(body.LastElementChild.TagName, "p", StringComparison.OrdinalIgnoreCase)
+ && IsBlankParagraph(body.LastElementChild))
+ {
+ body.LastElementChild.Remove();
+ }
+ }
+
+ private static bool IsBlankParagraph(IElement element)
+ {
+ var text = (element.TextContent ?? string.Empty).Replace('\u00a0', ' ');
+ return string.IsNullOrWhiteSpace(text)
+ && element.QuerySelector("img") == null
+ && element.QuerySelector("video") == null
+ && element.QuerySelector("iframe") == null;
+ }
+
+ private static string CanonicalImageSource(string value)
+ {
+ var trimmed = StripUrlBoundaryControls(WebUtility.HtmlDecode(value));
+ if (!Uri.TryCreate(trimmed, UriKind.RelativeOrAbsolute, out var uri)
+ || !string.Equals(uri.IsAbsoluteUri ? uri.AbsolutePath : uri.OriginalString.Split('?')[0], "/Public/ProxyImage", StringComparison.OrdinalIgnoreCase))
+ {
+ return trimmed;
+ }
+
+ var query = uri.IsAbsoluteUri ? uri.Query : new Uri(new Uri("https://wayfarer.local"), uri).Query;
+ var values = QueryHelpers.ParseQuery(query);
+ return values.TryGetValue("url", out var target) && target.Count > 0
+ ? StripUrlBoundaryControls(target[0] ?? trimmed)
+ : trimmed;
+ }
+
+ private static string StripUrlBoundaryControls(string value) =>
+ Regex.Replace(value, @"^[\u0000-\u0020\u007f-\u009f]+|[\u0000-\u0020\u007f-\u009f]+$", string.Empty);
+
+ private static string CompactUrlScheme(string value) =>
+ Regex.Replace(StripUrlBoundaryControls(value)[..Math.Min(64, StripUrlBoundaryControls(value).Length)], @"[\u0000-\u0020\u007f-\u009f]+", string.Empty).ToLowerInvariant();
+}
diff --git a/Models/Dtos/Editor/EditorSegmentRequestParser.cs b/Models/Dtos/Editor/EditorSegmentRequestParser.cs
index cfdd9279..93038a77 100644
--- a/Models/Dtos/Editor/EditorSegmentRequestParser.cs
+++ b/Models/Dtos/Editor/EditorSegmentRequestParser.cs
@@ -1,5 +1,4 @@
using System.Text.Json;
-using System.Text.RegularExpressions;
using NetTopologySuite.Geometries;
using Wayfarer.Services;
@@ -11,10 +10,6 @@ namespace Wayfarer.Models.Dtos.Editor;
internal static class EditorSegmentRequestParser
{
private static readonly string[] ServerOwnedFields = { "id", "tripId", "displayOrder", "capabilities" };
- private static readonly Regex DataImageSourceRegex = new(
- @"
]*?\bsrc\s*=\s*[""']?\s*data:image/",
- RegexOptions.Compiled | RegexOptions.IgnoreCase);
-
///
/// Attempts to parse a complete-draft segment save request.
///
@@ -49,7 +44,7 @@ public static bool TryParseSave(JsonElement request, out EditorSegmentSaveReques
CanonicalMode(mode),
distance,
duration,
- notesHtml,
+ EditorRichNotesRequestHtml.NormalizeForPersistence(notesHtml),
route);
return true;
}
@@ -288,7 +283,7 @@ private static string CanonicalMode(string? mode) =>
private static void ValidateNotes(string? notesHtml, Dictionary errors)
{
- if (!string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml))
+ if (EditorRichNotesRequestHtml.ContainsDataImageSource(notesHtml))
{
errors["notesHtml"] = new[] { "Notes cannot contain data image sources." };
}
diff --git a/Models/Dtos/Editor/EditorTripMetadataUpdateRequestParser.cs b/Models/Dtos/Editor/EditorTripMetadataUpdateRequestParser.cs
index dfb6732a..1324d8e7 100644
--- a/Models/Dtos/Editor/EditorTripMetadataUpdateRequestParser.cs
+++ b/Models/Dtos/Editor/EditorTripMetadataUpdateRequestParser.cs
@@ -1,5 +1,4 @@
using System.Text.Json;
-using System.Text.RegularExpressions;
namespace Wayfarer.Models.Dtos.Editor;
@@ -16,10 +15,6 @@ internal static class EditorTripMetadataUpdateRequestParser
"updatedAt"
};
- private static readonly Regex DataImageSourceRegex = new(
- @"
]*?\bsrc\s*=\s*[""']?\s*data:image/",
- RegexOptions.Compiled | RegexOptions.IgnoreCase);
-
///
/// Attempts to parse an editor metadata update request and returns field-keyed validation errors.
///
@@ -55,7 +50,7 @@ public static bool TryParse(
return false;
}
- update = new EditorTripMetadataUpdateRequest(name!, notesHtml, isPublic!.Value, coverImage, center, zoom);
+ update = new EditorTripMetadataUpdateRequest(name!, EditorRichNotesRequestHtml.NormalizeForPersistence(notesHtml), isPublic!.Value, coverImage, center, zoom);
return true;
}
@@ -267,7 +262,7 @@ private static void ValidateCoverImage(string? rawUrl, Dictionary errors)
{
- if (!string.IsNullOrEmpty(notesHtml) && DataImageSourceRegex.IsMatch(notesHtml))
+ if (EditorRichNotesRequestHtml.ContainsDataImageSource(notesHtml))
{
errors["notesHtml"] = new[] { "Notes images must use external image URLs, not data:image sources." };
}
diff --git a/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs b/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs
index 6469f306..7464858d 100644
--- a/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs
+++ b/tests/Wayfarer.Tests/Controllers/TripEditorMetadataControllerTests.cs
@@ -78,6 +78,29 @@ public async Task PatchMetadataNullNotesStoresEmptyString()
Assert.Equal(string.Empty, db.Trips.Single(t => t.Id == trip.Id).Notes);
}
+ [Fact]
+ public async Task PatchMetadataNormalizesRichNotesBeforePersisting()
+ {
+ using var db = CreateDbContext();
+ var trip = SeedTrip(db, "owner-user");
+ var controller = BuildController(db);
+ ConfigureControllerWithUserRole(controller, "owner-user");
+ const string expectedNotes = "Centered

";
+
+ var result = await PatchMetadata(controller, trip.Id, ValidMetadataJson(notesHtml: """
+ "Centered

"
+ """), CancellationToken.None);
+
+ var envelope = AssertMutation(result);
+ var stored = db.Trips.Single(t => t.Id == trip.Id);
+ Assert.Equal(expectedNotes, envelope.Data.NotesHtml);
+ Assert.Equal(expectedNotes, stored.Notes);
+ Assert.DoesNotContain("/Public/ProxyImage", stored.Notes);
+ Assert.DoesNotContain("onclick", stored.Notes);
+ Assert.DoesNotContain("onerror", stored.Notes);
+ Assert.DoesNotContain("
", stored.Notes);
+ }
+
[Theory]
[InlineData("null")]
[InlineData("""{ "rawUrl": null }""")]
diff --git a/tests/Wayfarer.Tests/Models/EditorRichNotesRequestHtmlTests.cs b/tests/Wayfarer.Tests/Models/EditorRichNotesRequestHtmlTests.cs
new file mode 100644
index 00000000..9562eb7b
--- /dev/null
+++ b/tests/Wayfarer.Tests/Models/EditorRichNotesRequestHtmlTests.cs
@@ -0,0 +1,175 @@
+using System.Text.Json;
+using Wayfarer.Models.Dtos.Editor;
+using Xunit;
+
+namespace Wayfarer.Tests.Models;
+
+///
+/// Contract tests for Trip Editor rich-notes request HTML before persistence.
+///
+public sealed class EditorRichNotesRequestHtmlTests
+{
+ [Fact]
+ public void NormalizeForPersistence_PreservesAllowedFormattingListsAlignmentLinksAndImages()
+ {
+ var input = string.Concat(
+ "Bold Italic Underline
",
+ "- Bullet
",
+ "- Ordered
",
+ "Left
",
+ "Center
",
+ "Right
",
+ "Serif
",
+ "Link
",
+ "
");
+
+ var result = EditorRichNotesRequestHtml.NormalizeForPersistence(input);
+
+ Assert.Contains("Bold", result);
+ Assert.Contains("Italic", result);
+ Assert.Contains("Underline", result);
+ Assert.Contains("data-list=\"bullet\"", result);
+ Assert.Contains("data-list=\"ordered\"", result);
+ Assert.Contains("Left
", result);
+ Assert.Contains("Center
", result);
+ Assert.Contains("Right
", result);
+ Assert.Contains("Serif", result);
+ Assert.Contains("href=\"https://example.test/page\"", result);
+ Assert.Contains("src=\"https://cdn.example.test/image.jpg\"", result);
+ Assert.DoesNotContain("ql-align-left", result);
+ Assert.DoesNotContain("ql-ui", result);
+ Assert.DoesNotContain("onclick", result);
+ Assert.DoesNotContain("onerror", result);
+ }
+
+ [Fact]
+ public void NormalizeForPersistence_StripsQuillClassesFromUnsupportedElements()
+ {
+ var result = EditorRichNotesRequestHtml.NormalizeForPersistence(
+ "Inline alignment
Block font
");
+
+ Assert.Contains("Inline alignment", result);
+ Assert.Contains("Block font
", result);
+ Assert.DoesNotContain("ql-align-right", result);
+ Assert.DoesNotContain("ql-font-serif", result);
+ }
+
+ [Fact]
+ public void NormalizeForPersistence_UnwrapsProxyImageUrlsBeforeSave()
+ {
+ var result = EditorRichNotesRequestHtml.NormalizeForPersistence(
+ "
");
+
+ Assert.Contains("src=\"https://cdn.example.test/proxied.jpg\"", result);
+ Assert.DoesNotContain("/Public/ProxyImage", result);
+ }
+
+ [Fact]
+ public void NormalizeForPersistence_StripsDataImagesUnsafeUrlsAndScripts()
+ {
+ var input = string.Concat(
+ "",
+ "Safe text
",
+ "Unsafe link
",
+ "
",
+ "\")
");
+
+ var result = EditorRichNotesRequestHtml.NormalizeForPersistence(input);
+
+ Assert.Contains("Safe text", result);
+ Assert.Contains("Unsafe link", result);
+ Assert.DoesNotContain("