From 1d391f36ed38e28f7055f8d70d632c51e52e7fb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Jasi=C5=84ski?= Date: Mon, 8 Jun 2026 13:55:40 +0200 Subject: [PATCH] feat: implement stage 2 of Milkdown integration (secure upload & xss guard) --- Directory.Packages.props | 1 + .../Services/ISanitizerService.cs | 12 +++ .../Abstractions/Services/IStorageService.cs | 17 ++++ .../Common/AppJsonContext.cs | 3 + .../DTOs/Media/MediaDtos.cs | 16 ++++ .../DependencyInjection.cs | 2 + .../NexusReader.Infrastructure.csproj | 1 + .../Services/HtmlSanitizerService.cs | 30 +++++++ .../Services/LocalStorageService.cs | 47 ++++++++++ .../Components/MarkdownEditor.razor | 32 +++++++ .../Pages/CreatorTest.razor | 82 +++++++++++++++--- .../Pages/CreatorTest.razor.css | 33 +++++++ .../wwwroot/js/milkdownWrapper.js | 17 +++- src/NexusReader.Web/Program.cs | 86 +++++++++++++++++++ .../Services/HtmlSanitizerServiceTests.cs | 54 ++++++++++++ 15 files changed, 419 insertions(+), 14 deletions(-) create mode 100644 src/NexusReader.Application/Abstractions/Services/ISanitizerService.cs create mode 100644 src/NexusReader.Application/Abstractions/Services/IStorageService.cs create mode 100644 src/NexusReader.Application/DTOs/Media/MediaDtos.cs create mode 100644 src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs create mode 100644 src/NexusReader.Infrastructure/Services/LocalStorageService.cs create mode 100644 tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 0f630a0..c9be3b9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + diff --git a/src/NexusReader.Application/Abstractions/Services/ISanitizerService.cs b/src/NexusReader.Application/Abstractions/Services/ISanitizerService.cs new file mode 100644 index 0000000..3c5d892 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/ISanitizerService.cs @@ -0,0 +1,12 @@ +namespace NexusReader.Application.Abstractions.Services; + +/// +/// Service for sanitizing raw input text (e.g. Markdown/HTML) to protect against XSS injection. +/// +public interface ISanitizerService +{ + /// + /// Sanitizes the input string and returns a clean, safe version. + /// + string Sanitize(string input); +} diff --git a/src/NexusReader.Application/Abstractions/Services/IStorageService.cs b/src/NexusReader.Application/Abstractions/Services/IStorageService.cs new file mode 100644 index 0000000..95261f9 --- /dev/null +++ b/src/NexusReader.Application/Abstractions/Services/IStorageService.cs @@ -0,0 +1,17 @@ +namespace NexusReader.Application.Abstractions.Services; + +/// +/// General file storage service interface for handling media uploads. +/// +public interface IStorageService +{ + /// + /// Uploads a file stream and returns its public URL/path. + /// + Task UploadFileAsync(Stream fileStream, string fileName, string contentType); + + /// + /// Uploads file bytes and returns its public URL/path. + /// + Task UploadFileAsync(byte[] fileBytes, string fileName, string contentType); +} diff --git a/src/NexusReader.Application/Common/AppJsonContext.cs b/src/NexusReader.Application/Common/AppJsonContext.cs index 387442f..a8323a4 100644 --- a/src/NexusReader.Application/Common/AppJsonContext.cs +++ b/src/NexusReader.Application/Common/AppJsonContext.cs @@ -18,6 +18,9 @@ namespace NexusReader.Application.Common; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(NexusReader.Application.DTOs.User.UpdateThemeRequest))] [JsonSerializable(typeof(NexusReader.Domain.Enums.ThemeMode))] +[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterRequest))] +[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.ValidateChapterResponse))] +[JsonSerializable(typeof(NexusReader.Application.DTOs.Media.UploadResultDto))] public partial class AppJsonContext : JsonSerializerContext { } diff --git a/src/NexusReader.Application/DTOs/Media/MediaDtos.cs b/src/NexusReader.Application/DTOs/Media/MediaDtos.cs new file mode 100644 index 0000000..ab27c65 --- /dev/null +++ b/src/NexusReader.Application/DTOs/Media/MediaDtos.cs @@ -0,0 +1,16 @@ +namespace NexusReader.Application.DTOs.Media; + +/// +/// Request DTO for chapter validation/sanitization. +/// +public record ValidateChapterRequest(string Content); + +/// +/// Response DTO containing sanitized chapter content. +/// +public record ValidateChapterResponse(string SanitizedContent); + +/// +/// Response DTO containing the uploaded media file URL. +/// +public record UploadResultDto(string Url); diff --git a/src/NexusReader.Infrastructure/DependencyInjection.cs b/src/NexusReader.Infrastructure/DependencyInjection.cs index 13801ad..404d247 100644 --- a/src/NexusReader.Infrastructure/DependencyInjection.cs +++ b/src/NexusReader.Infrastructure/DependencyInjection.cs @@ -124,6 +124,8 @@ public static class DependencyInjection // Fix #4: Scoped instead of Singleton — BookStorageService uses path resolution // that is environment-specific and incompatible with Singleton lifetime in MAUI. services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); // Fix #1: Ebook repository (scoped, matches AppDbContext lifetime) services.AddScoped(); diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj index 0f5ea00..b423dfb 100644 --- a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj +++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj @@ -28,6 +28,7 @@ + diff --git a/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs b/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs new file mode 100644 index 0000000..753f6d6 --- /dev/null +++ b/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs @@ -0,0 +1,30 @@ +using Ganss.Xss; +using NexusReader.Application.Abstractions.Services; + +namespace NexusReader.Infrastructure.Services; + +/// +/// Infrastructure implementation of ISanitizerService using the Ganss.Xss HtmlSanitizer library. +/// +public class HtmlSanitizerService : ISanitizerService +{ + private readonly HtmlSanitizer _sanitizer; + + public HtmlSanitizerService() + { + _sanitizer = new HtmlSanitizer(); + + // Use default configuration which is extremely secure and strips + // all JavaScript (script tags, onerror, onload, iframe, etc.) + } + + public string Sanitize(string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + + return _sanitizer.Sanitize(input); + } +} diff --git a/src/NexusReader.Infrastructure/Services/LocalStorageService.cs b/src/NexusReader.Infrastructure/Services/LocalStorageService.cs new file mode 100644 index 0000000..2adad67 --- /dev/null +++ b/src/NexusReader.Infrastructure/Services/LocalStorageService.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Hosting; +using NexusReader.Application.Abstractions.Services; + +namespace NexusReader.Infrastructure.Services; + +/// +/// Infrastructure implementation of general storage utilizing local filesystem. +/// Files are saved in wwwroot/uploads/media. +/// +public class LocalStorageService : IStorageService +{ + private readonly IWebHostEnvironment _environment; + + public LocalStorageService(IWebHostEnvironment environment) + { + _environment = environment; + } + + public async Task UploadFileAsync(byte[] fileBytes, string fileName, string contentType) + { + using var stream = new MemoryStream(fileBytes); + return await UploadFileAsync(stream, fileName, contentType); + } + + public async Task UploadFileAsync(Stream fileStream, string fileName, string contentType) + { + var mediaFolder = Path.Combine(_environment.WebRootPath, "uploads", "media"); + + if (!Directory.Exists(mediaFolder)) + { + Directory.CreateDirectory(mediaFolder); + } + + // Clean file name to prevent path traversal issues + var safeFileName = Path.GetFileName(fileName); + var uniqueFileName = $"{Guid.NewGuid()}_{safeFileName}"; + var filePath = Path.Combine(mediaFolder, uniqueFileName); + + using (var outputStream = new FileStream(filePath, FileMode.Create)) + { + await fileStream.CopyToAsync(outputStream); + } + + // Return the public web-relative URL + return $"/uploads/media/{uniqueFileName}"; + } +} diff --git a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor index 0adbe19..550de4b 100644 --- a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor +++ b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor @@ -1,6 +1,7 @@ @using Microsoft.JSInterop @implements IAsyncDisposable @inject IJSRuntime JS +@inject HttpClient Http
@@ -73,6 +74,37 @@ } } + [JSInvokable] + public async Task UploadImageFromJs(string filename, string contentType, byte[] fileBytes) + { + try + { + using var content = new MultipartFormDataContent(); + var fileContent = new ByteArrayContent(fileBytes); + fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType); + content.Add(fileContent, "file", filename); + + var response = await Http.PostAsync("/api/media/upload", content); + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync( + NexusReader.Application.Common.AppJsonContext.Default.UploadResultDto); + return result?.Url ?? string.Empty; + } + else + { + var errorMsg = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"[MarkdownEditor] Image upload failed: {response.StatusCode} - {errorMsg}"); + return string.Empty; + } + } + catch (Exception ex) + { + Console.WriteLine($"[MarkdownEditor] Exception during image upload: {ex.Message}"); + return string.Empty; + } + } + public async ValueTask DisposeAsync() { try diff --git a/src/NexusReader.UI.Shared/Pages/CreatorTest.razor b/src/NexusReader.UI.Shared/Pages/CreatorTest.razor index 8d40fa7..1c5910c 100644 --- a/src/NexusReader.UI.Shared/Pages/CreatorTest.razor +++ b/src/NexusReader.UI.Shared/Pages/CreatorTest.razor @@ -1,13 +1,14 @@ @page "/dev/creator-test" @using Microsoft.AspNetCore.Authorization @attribute [AllowAnonymous] +@inject HttpClient Http Markdown Creator Test
-

Milkdown WYSIWYG Integration (Stage 1)

-

Verifying bi-directional Markdown flow and GFM rendering.

+

Milkdown WYSIWYG Integration (Stage 2)

+

Verifying secure image upload (drag & drop / paste) and backend XSS sanitization.

@@ -15,7 +16,15 @@
-

Retrieved Markdown Content

+
+

Retrieved Markdown Content

+ @if (!string.IsNullOrEmpty(_savedMarkdown)) + { + + } +

This block shows the content received from the editor when you click "Fetch Markdown Content".

@if (string.IsNullOrEmpty(_savedMarkdown)) @@ -27,14 +36,35 @@
@_savedMarkdown
}
+ + @if (!string.IsNullOrEmpty(_sanitizedMarkdown)) + { +
+

Sanitized Content (XSS Shielded)

+

This block shows the content after passing through the backend `HtmlSanitizer` API.

+
+
@_sanitizedMarkdown
+
+
+ }
@code { - private readonly string _initialMarkdown = @"# Milkdown WYSIWYG Test Page + private readonly string _initialMarkdown = @"# Milkdown WYSIWYG Test Page (Stage 2) This is a demonstration of the **Milkdown** editor embedded inside a Blazor WASM component. +## Secure Image Upload (Drag & Drop / Paste) + +You can drag and drop or copy-paste an image (JPEG, PNG, WEBP) directly into this editor area. The file will be intercepted, sent as a byte stream to the backend `/api/media/upload` endpoint, validated for magic numbers and file size limits (max 5MB), saved locally, and rendered inside the editor. + +## XSS Security Test + +Here is some malicious HTML to test the backend XSS Guard: + + + ## GFM Features Support The editor supports Github Flavored Markdown out-of-the-box: @@ -42,7 +72,7 @@ The editor supports Github Flavored Markdown out-of-the-box: 1. **Task Lists** - [x] Create reusable Blazor component - [x] Configure ESM dynamic wrapper - - [ ] Implement stage 2 features + - [x] Implement stage 2 features 2. **Tables** @@ -52,21 +82,47 @@ The editor supports Github Flavored Markdown out-of-the-box: | C# Interop | Done | Auto-Sync | | GFM Support | Verified | Custom Nodes | -3. **Code Formatting** -```csharp -public class MarkdownEditor : ComponentBase -{ - // C# interop logic -} -``` - Feel free to edit this text and click **Fetch Markdown Content** below!"; private string _savedMarkdown = string.Empty; + private string _sanitizedMarkdown = string.Empty; private void HandleSave(string markdown) { _savedMarkdown = markdown; + _sanitizedMarkdown = string.Empty; // Reset sanitization result + StateHasChanged(); + } + + private async Task SanitizeContentAsync() + { + if (string.IsNullOrEmpty(_savedMarkdown)) return; + + try + { + var request = new NexusReader.Application.DTOs.Media.ValidateChapterRequest(_savedMarkdown); + var response = await Http.PostAsJsonAsync( + "/api/chapters/validate", + request, + NexusReader.Application.Common.AppJsonContext.Default.ValidateChapterRequest + ); + + if (response.IsSuccessStatusCode) + { + var result = await response.Content.ReadFromJsonAsync( + NexusReader.Application.Common.AppJsonContext.Default.ValidateChapterResponse + ); + _sanitizedMarkdown = result?.SanitizedContent ?? string.Empty; + } + else + { + _sanitizedMarkdown = $"Error: {response.StatusCode}"; + } + } + catch (Exception ex) + { + _sanitizedMarkdown = $"Exception: {ex.Message}"; + } StateHasChanged(); } } diff --git a/src/NexusReader.UI.Shared/Pages/CreatorTest.razor.css b/src/NexusReader.UI.Shared/Pages/CreatorTest.razor.css index 4e4fa67..f30b05d 100644 --- a/src/NexusReader.UI.Shared/Pages/CreatorTest.razor.css +++ b/src/NexusReader.UI.Shared/Pages/CreatorTest.razor.css @@ -37,12 +37,45 @@ padding: 1.5rem; } +.result-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 0.5rem; +} + .result-section h3 { margin: 0; font-size: 1.2rem; color: var(--text-main); } +.sanitized-container { + margin-top: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + border-top: 1px dashed var(--border); + padding-top: 1.5rem; +} + +.sanitized-wrapper { + border-color: rgba(0, 255, 153, 0.25); + box-shadow: 0 0 10px rgba(0, 255, 153, 0.05); +} + +.nexus-btn.secondary-btn { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #ffffff; +} + +.nexus-btn.secondary-btn:hover { + background: rgba(255, 255, 255, 0.1); + box-shadow: 0 4px 15px rgba(255, 255, 255, 0.05); +} + .result-section .description { font-size: 0.85rem; color: var(--text-muted); diff --git a/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js b/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js index 5d4649e..4d25273 100644 --- a/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js +++ b/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js @@ -39,10 +39,25 @@ export async function initEditor(elementId, dotNetHelper, initialMarkdown) { // Dynamically import the Crepe ESM module const { Crepe } = await import('https://esm.sh/@milkdown/crepe'); - // Initialize the Crepe editor instance + // Initialize the Crepe editor instance with custom ImageBlock upload handler const crepe = new Crepe({ root: container, defaultValue: initialMarkdown || "", + featureConfigs: { + [Crepe.Feature.ImageBlock]: { + onUpload: async (file) => { + try { + const arrayBuffer = await file.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + const url = await dotNetHelper.invokeMethodAsync('UploadImageFromJs', file.name, file.type, uint8Array); + return url; + } catch (err) { + console.error("[Milkdown] Failed to upload image from JS:", err); + throw err; + } + } + } + } }); // Store the editor instance in the map diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs index dfe8d42..24e54e5 100644 --- a/src/NexusReader.Web/Program.cs +++ b/src/NexusReader.Web/Program.cs @@ -769,6 +769,64 @@ app.MapPost("/identity/theme", async ( return Results.Ok(); }).RequireAuthorization(); +app.MapPost("/api/media/upload", async ( + HttpRequest request, + NexusReader.Application.Abstractions.Services.IStorageService storageService, + ILogger logger) => +{ + if (!request.HasFormContentType) + { + return Results.BadRequest("Request must be a multipart form."); + } + + var form = await request.ReadFormAsync(); + var file = form.Files.GetFile("file"); + + if (file == null || file.Length == 0) + { + return Results.BadRequest("No file uploaded."); + } + + // Size limit check (max 5MB) + const long maxFileSize = 5 * 1024 * 1024; + if (file.Length > maxFileSize) + { + return Results.BadRequest("File size exceeds the 5MB limit."); + } + + // Read file bytes for signature check + byte[] fileBytes; + using (var memoryStream = new MemoryStream()) + { + await file.CopyToAsync(memoryStream); + fileBytes = memoryStream.ToArray(); + } + + // Validate signature + if (!ValidateImageSignature(fileBytes, file.ContentType)) + { + logger.LogWarning("File signature validation failed for file {FileName} with content type {ContentType}.", file.FileName, file.ContentType); + return Results.BadRequest("Invalid image signature. Legitimate JPEG, PNG, or WEBP images only."); + } + + // Save using IStorageService + var fileUrl = await storageService.UploadFileAsync(fileBytes, file.FileName, file.ContentType); + return Results.Ok(new NexusReader.Application.DTOs.Media.UploadResultDto(fileUrl)); +}).DisableAntiforgery(); + +app.MapPost("/api/chapters/validate", ( + [Microsoft.AspNetCore.Mvc.FromBody] NexusReader.Application.DTOs.Media.ValidateChapterRequest request, + NexusReader.Application.Abstractions.Services.ISanitizerService sanitizerService) => +{ + if (request == null || string.IsNullOrEmpty(request.Content)) + { + return Results.Ok(new NexusReader.Application.DTOs.Media.ValidateChapterResponse(string.Empty)); + } + + var sanitized = sanitizerService.Sanitize(request.Content); + return Results.Ok(new NexusReader.Application.DTOs.Media.ValidateChapterResponse(sanitized)); +}).DisableAntiforgery(); + app.MapRazorComponents() .AddInteractiveServerRenderMode() .AddInteractiveWebAssemblyRenderMode() @@ -820,6 +878,34 @@ async Task TriggerBackgroundProcessingForUnindexedBooksAsync(IServiceProvider se } } +static bool ValidateImageSignature(byte[] bytes, string contentType) +{ + if (bytes.Length < 4) return false; + + // Check PNG signature: 89 50 4E 47 + if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47) + { + return contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase); + } + + // Check JPEG signature: FF D8 FF + if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) + { + return contentType.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) || + contentType.Equals("image/jpg", StringComparison.OrdinalIgnoreCase); + } + + // Check WEBP signature: RIFF ... WEBP + if (bytes.Length >= 12 && + bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && // RIFF + bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) // WEBP + { + return contentType.Equals("image/webp", StringComparison.OrdinalIgnoreCase); + } + + return false; +} + public record KnowledgeRequest(string Text, Guid? EbookId = null); public record GroundednessRequest(string Answer, string Context); public record SemanticSearchRequest(string QueryText, int Limit = 5); diff --git a/tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs b/tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs new file mode 100644 index 0000000..013b78c --- /dev/null +++ b/tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs @@ -0,0 +1,54 @@ +using FluentAssertions; +using NexusReader.Infrastructure.Services; +using Xunit; + +namespace NexusReader.Application.Tests.Services; + +public class HtmlSanitizerServiceTests +{ + [Fact] + public void Sanitize_WithSafeInput_ReturnsSameInput() + { + // Arrange + var service = new HtmlSanitizerService(); + var input = "

This is a safe paragraph.

"; + + // Act + var result = service.Sanitize(input); + + // Assert + result.Should().Be(input); + } + + [Fact] + public void Sanitize_WithScriptTag_StripsScriptTag() + { + // Arrange + var service = new HtmlSanitizerService(); + var input = "

Hello

"; + + // Act + var result = service.Sanitize(input); + + // Assert + result.Should().NotContain("