feat: implement stage 2 of Milkdown integration (secure upload & xss guard)

This commit is contained in:
2026-06-08 13:55:40 +02:00
parent 79fc43d592
commit 1d391f36ed
15 changed files with 419 additions and 14 deletions
+1
View File
@@ -4,6 +4,7 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="FluentResults" Version="4.0.0" />
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
<PackageVersion Include="Mapster" Version="10.0.7" />
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.7" />
<PackageVersion Include="MediatR" Version="12.1.1" />
@@ -0,0 +1,12 @@
namespace NexusReader.Application.Abstractions.Services;
/// <summary>
/// Service for sanitizing raw input text (e.g. Markdown/HTML) to protect against XSS injection.
/// </summary>
public interface ISanitizerService
{
/// <summary>
/// Sanitizes the input string and returns a clean, safe version.
/// </summary>
string Sanitize(string input);
}
@@ -0,0 +1,17 @@
namespace NexusReader.Application.Abstractions.Services;
/// <summary>
/// General file storage service interface for handling media uploads.
/// </summary>
public interface IStorageService
{
/// <summary>
/// Uploads a file stream and returns its public URL/path.
/// </summary>
Task<string> UploadFileAsync(Stream fileStream, string fileName, string contentType);
/// <summary>
/// Uploads file bytes and returns its public URL/path.
/// </summary>
Task<string> UploadFileAsync(byte[] fileBytes, string fileName, string contentType);
}
@@ -18,6 +18,9 @@ namespace NexusReader.Application.Common;
[JsonSerializable(typeof(List<NexusReader.Application.Queries.Recommendations.RecommendationDto>))]
[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
{
}
@@ -0,0 +1,16 @@
namespace NexusReader.Application.DTOs.Media;
/// <summary>
/// Request DTO for chapter validation/sanitization.
/// </summary>
public record ValidateChapterRequest(string Content);
/// <summary>
/// Response DTO containing sanitized chapter content.
/// </summary>
public record ValidateChapterResponse(string SanitizedContent);
/// <summary>
/// Response DTO containing the uploaded media file URL.
/// </summary>
public record UploadResultDto(string Url);
@@ -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<IBookStorageService, BookStorageService>();
services.AddScoped<IStorageService, LocalStorageService>();
services.AddSingleton<ISanitizerService, HtmlSanitizerService>();
// Fix #1: Ebook repository (scoped, matches AppDbContext lifetime)
services.AddScoped<IEbookRepository, EbookRepository>();
@@ -28,6 +28,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Polly" />
<PackageReference Include="Polly.Extensions.Http" />
<PackageReference Include="HtmlSanitizer" />
<PackageReference Include="Qdrant.Client" />
<PackageReference Include="Stripe.net" />
<PackageReference Include="VersOne.Epub" />
@@ -0,0 +1,30 @@
using Ganss.Xss;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Infrastructure.Services;
/// <summary>
/// Infrastructure implementation of ISanitizerService using the Ganss.Xss HtmlSanitizer library.
/// </summary>
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);
}
}
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Hosting;
using NexusReader.Application.Abstractions.Services;
namespace NexusReader.Infrastructure.Services;
/// <summary>
/// Infrastructure implementation of general storage utilizing local filesystem.
/// Files are saved in wwwroot/uploads/media.
/// </summary>
public class LocalStorageService : IStorageService
{
private readonly IWebHostEnvironment _environment;
public LocalStorageService(IWebHostEnvironment environment)
{
_environment = environment;
}
public async Task<string> UploadFileAsync(byte[] fileBytes, string fileName, string contentType)
{
using var stream = new MemoryStream(fileBytes);
return await UploadFileAsync(stream, fileName, contentType);
}
public async Task<string> 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}";
}
}
@@ -1,6 +1,7 @@
@using Microsoft.JSInterop
@implements IAsyncDisposable
@inject IJSRuntime JS
@inject HttpClient Http
<div class="markdown-editor-container" style="height: @Height; width: @Width;">
<div id="@EditorId" class="milkdown-editor-wrapper"></div>
@@ -73,6 +74,37 @@
}
}
[JSInvokable]
public async Task<string> 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.DTOs.Media.UploadResultDto>(
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
@@ -1,13 +1,14 @@
@page "/dev/creator-test"
@using Microsoft.AspNetCore.Authorization
@attribute [AllowAnonymous]
@inject HttpClient Http
<PageTitle>Markdown Creator Test</PageTitle>
<div class="creator-test-container glass-panel">
<div class="test-header">
<h1>Milkdown WYSIWYG Integration (Stage 1)</h1>
<p class="subtitle">Verifying bi-directional Markdown flow and GFM rendering.</p>
<h1>Milkdown WYSIWYG Integration (Stage 2)</h1>
<p class="subtitle">Verifying secure image upload (drag &amp; drop / paste) and backend XSS sanitization.</p>
</div>
<div class="editor-section">
@@ -15,7 +16,15 @@
</div>
<div class="result-section">
<h3>Retrieved Markdown Content</h3>
<div class="result-header">
<h3>Retrieved Markdown Content</h3>
@if (!string.IsNullOrEmpty(_savedMarkdown))
{
<button type="button" @onclick="SanitizeContentAsync" class="nexus-btn secondary-btn">
Run XSS Guard Sanitization
</button>
}
</div>
<p class="description">This block shows the content received from the editor when you click "Fetch Markdown Content".</p>
<div class="pre-wrapper">
@if (string.IsNullOrEmpty(_savedMarkdown))
@@ -27,14 +36,35 @@
<pre><code>@_savedMarkdown</code></pre>
}
</div>
@if (!string.IsNullOrEmpty(_sanitizedMarkdown))
{
<div class="sanitized-container">
<h3>Sanitized Content (XSS Shielded)</h3>
<p class="description">This block shows the content after passing through the backend `HtmlSanitizer` API.</p>
<div class="pre-wrapper sanitized-wrapper">
<pre><code>@_sanitizedMarkdown</code></pre>
</div>
</div>
}
</div>
</div>
@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:
<script>alert('xss')</script>
<img src=x onerror=alert(1)>
## 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.DTOs.Media.ValidateChapterResponse>(
NexusReader.Application.Common.AppJsonContext.Default.ValidateChapterResponse
);
_sanitizedMarkdown = result?.SanitizedContent ?? string.Empty;
}
else
{
_sanitizedMarkdown = $"Error: {response.StatusCode}";
}
}
catch (Exception ex)
{
_sanitizedMarkdown = $"Exception: {ex.Message}";
}
StateHasChanged();
}
}
@@ -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);
@@ -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
+86
View File
@@ -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<Program> 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<App>()
.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);
@@ -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 = "<p>This is a safe <strong>paragraph</strong>.</p>";
// Act
var result = service.Sanitize(input);
// Assert
result.Should().Be(input);
}
[Fact]
public void Sanitize_WithScriptTag_StripsScriptTag()
{
// Arrange
var service = new HtmlSanitizerService();
var input = "<p>Hello</p><script>alert('xss')</script>";
// Act
var result = service.Sanitize(input);
// Assert
result.Should().NotContain("<script>");
result.Should().NotContain("alert");
result.Should().Be("<p>Hello</p>");
}
[Fact]
public void Sanitize_WithOnEventHandlerAttribute_StripsOnError()
{
// Arrange
var service = new HtmlSanitizerService();
var input = "<img src=\"x\" onerror=\"alert(1)\" />";
// Act
var result = service.Sanitize(input);
// Assert
result.Should().NotContain("onerror");
result.Should().NotContain("alert");
result.Should().Contain("<img src=\"x\">");
}
}