diff --git a/Directory.Packages.props b/Directory.Packages.props
index c9be3b9..f7f6319 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -5,6 +5,7 @@
+
diff --git a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj
index b423dfb..47a463f 100644
--- a/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj
+++ b/src/NexusReader.Infrastructure/NexusReader.Infrastructure.csproj
@@ -29,6 +29,7 @@
+
diff --git a/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs b/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs
index a08c967..ee86725 100644
--- a/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs
+++ b/src/NexusReader.Infrastructure/Services/HtmlSanitizerService.cs
@@ -2,6 +2,7 @@ using Ganss.Xss;
using Microsoft.Extensions.Options;
using NexusReader.Application.Abstractions.Services;
using NexusReader.Infrastructure.Configuration;
+using Markdig;
namespace NexusReader.Infrastructure.Services;
@@ -11,10 +12,12 @@ namespace NexusReader.Infrastructure.Services;
public class HtmlSanitizerService : ISanitizerService
{
private readonly HtmlSanitizer _sanitizer;
+ private readonly MarkdownPipeline _pipeline;
public HtmlSanitizerService(IOptions? options = null)
{
_sanitizer = new HtmlSanitizer();
+ _pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
if (options?.Value != null)
{
@@ -65,6 +68,9 @@ public class HtmlSanitizerService : ISanitizerService
return input;
}
- return _sanitizer.Sanitize(input);
+ // Translate raw Markdown input to HTML strictly before running HtmlSanitizer
+ var html = Markdown.ToHtml(input, _pipeline);
+
+ return _sanitizer.Sanitize(html).Trim();
}
}
diff --git a/src/NexusReader.Infrastructure/Services/LocalStorageService.cs b/src/NexusReader.Infrastructure/Services/LocalStorageService.cs
index 6b61ec5..d8803f8 100644
--- a/src/NexusReader.Infrastructure/Services/LocalStorageService.cs
+++ b/src/NexusReader.Infrastructure/Services/LocalStorageService.cs
@@ -24,7 +24,7 @@ public class LocalStorageService : IStorageService
public async Task UploadFileAsync(Stream fileStream, string fileName, string contentType)
{
- var mediaFolder = Path.Combine(_environment.WebRootPath, "uploads", "media");
+ var mediaFolder = Path.Combine(_environment.WebRootPath, "uploads");
var resolvedMediaFolder = Path.GetFullPath(mediaFolder);
var folderWithSeparator = resolvedMediaFolder.EndsWith(Path.DirectorySeparatorChar)
? resolvedMediaFolder
@@ -53,6 +53,6 @@ public class LocalStorageService : IStorageService
}
// Return the public web-relative URL
- return $"/uploads/media/{uniqueFileName}";
+ return $"/uploads/{uniqueFileName}";
}
}
diff --git a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor
index 01ce06b..0529d26 100644
--- a/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor
+++ b/src/NexusReader.UI.Shared/Components/MarkdownEditor.razor
@@ -82,12 +82,18 @@
}
[JSInvokable]
- public async Task UploadImageFromJs(string filename, string contentType, byte[] fileBytes)
+ public async Task UploadImageFromJs(string filename, string contentType, IJSStreamReference streamRef)
{
try
{
+ const long maxFileSize = 5 * 1024 * 1024; // 5MB limit
+ using var stream = await streamRef.OpenReadStreamAsync(maxFileSize, _cts.Token);
+ using var memoryStream = new MemoryStream();
+ await stream.CopyToAsync(memoryStream, _cts.Token);
+ var fileBytes = memoryStream.ToArray();
+
using var content = new MultipartFormDataContent();
- var fileContent = new ByteArrayContent(fileBytes);
+ using var fileContent = new ByteArrayContent(fileBytes);
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
content.Add(fileContent, "file", filename);
@@ -96,19 +102,19 @@
{
var result = await response.Content.ReadFromJsonAsync(
NexusReader.Application.Common.AppJsonContext.Default.UploadResultDto, _cts.Token);
- return result?.Url ?? string.Empty;
+ return result?.Url ?? "https://placehold.co/600x400?text=Upload+Failed";
}
else
{
- var errorMsg = await response.Content.ReadAsStringAsync();
+ var errorMsg = await response.Content.ReadAsStringAsync(_cts.Token);
Console.WriteLine($"[MarkdownEditor] Image upload failed: {response.StatusCode} - {errorMsg}");
- return string.Empty;
+ return "https://placehold.co/600x400?text=Upload+Failed";
}
}
catch (Exception ex)
{
Console.WriteLine($"[MarkdownEditor] Exception during image upload: {ex.Message}");
- return string.Empty;
+ return "https://placehold.co/600x400?text=Upload+Failed";
}
}
diff --git a/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js b/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js
index 2d89599..fcb7c14 100644
--- a/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js
+++ b/src/NexusReader.UI.Shared/wwwroot/js/milkdownWrapper.js
@@ -50,12 +50,11 @@ export async function initEditor(elementId, dotNetHelper, initialMarkdown) {
[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);
+ const streamRef = DotNet.createJSStreamReference(file);
+ const url = await dotNetHelper.invokeMethodAsync('UploadImageFromJs', file.name, file.type, streamRef);
return url;
} catch (err) {
- console.error("[Milkdown] Failed to upload image from JS:", err);
+ console.error("[Milkdown] Failed to upload image from JS (onUpload):", err);
throw err;
}
}
@@ -63,6 +62,36 @@ export async function initEditor(elementId, dotNetHelper, initialMarkdown) {
}
});
+ // Configure custom uploader using the uploadConfig context slice
+ crepe.editor.config((ctx) => {
+ try {
+ ctx.update('uploadConfig', (prev) => ({
+ ...prev,
+ uploader: async (files, schema) => {
+ const nodes = [];
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+ if (file.type.startsWith('image/')) {
+ try {
+ const streamRef = DotNet.createJSStreamReference(file);
+ const uploadedUrl = await dotNetHelper.invokeMethodAsync('UploadImageFromJs', file.name, file.type, streamRef);
+ if (uploadedUrl) {
+ const node = schema.nodes.image.create({ src: uploadedUrl, alt: file.name });
+ nodes.push(node);
+ }
+ } catch (err) {
+ console.error("[Milkdown] Failed to upload image in custom uploader:", err);
+ }
+ }
+ }
+ return nodes;
+ }
+ }));
+ } catch (err) {
+ console.error("[Milkdown] Failed to configure uploadConfig uploader:", err);
+ }
+ });
+
// Store the editor instance in the map
editorCache.set(elementId, crepe);
diff --git a/src/NexusReader.Web/Program.cs b/src/NexusReader.Web/Program.cs
index 24e54e5..cf7f6cb 100644
--- a/src/NexusReader.Web/Program.cs
+++ b/src/NexusReader.Web/Program.cs
@@ -802,15 +802,15 @@ app.MapPost("/api/media/upload", async (
fileBytes = memoryStream.ToArray();
}
- // Validate signature
- if (!ValidateImageSignature(fileBytes, file.ContentType))
+ // Validate signature without trusting browser content-type, enforcing extension matching
+ if (!ImageValidator.ValidateImageSignature(fileBytes, file.FileName, out var detectedContentType))
{
- 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.");
+ logger.LogWarning("File signature validation failed for file {FileName} with browser content type {ContentType}.", file.FileName, file.ContentType);
+ return Results.BadRequest("Invalid file signature or extension mismatch. Legitimate JPEG, PNG, WEBP, or GIF images only.");
}
- // Save using IStorageService
- var fileUrl = await storageService.UploadFileAsync(fileBytes, file.FileName, file.ContentType);
+ // Save using IStorageService with the verified content type
+ var fileUrl = await storageService.UploadFileAsync(fileBytes, file.FileName, detectedContentType);
return Results.Ok(new NexusReader.Application.DTOs.Media.UploadResultDto(fileUrl));
}).DisableAntiforgery();
@@ -878,32 +878,56 @@ async Task TriggerBackgroundProcessingForUnindexedBooksAsync(IServiceProvider se
}
}
-static bool ValidateImageSignature(byte[] bytes, string contentType)
+public static class ImageValidator
{
- 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)
+ public static bool ValidateImageSignature(byte[] bytes, string fileName, out string detectedContentType)
{
- return contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase);
- }
+ detectedContentType = string.Empty;
+ if (bytes.Length < 4) return false;
- // 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 PNG signature: 89 50 4E 47
+ if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
+ {
+ detectedContentType = "image/png";
+ }
+ // Check JPEG signature: FF D8 FF
+ else if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
+ {
+ detectedContentType = "image/jpeg";
+ }
+ // Check WEBP signature: RIFF ... WEBP
+ else 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
+ {
+ detectedContentType = "image/webp";
+ }
+ // Check GIF signature: GIF87a or GIF89a
+ else if (bytes.Length >= 6 &&
+ bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38 &&
+ (bytes[4] == 0x37 || bytes[4] == 0x39) && bytes[5] == 0x61)
+ {
+ detectedContentType = "image/gif";
+ }
- // 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);
- }
+ if (string.IsNullOrEmpty(detectedContentType))
+ {
+ return false;
+ }
- return false;
+ // Verify that the file extension matches the detected content type (extension-spoofing guard)
+ var ext = Path.GetExtension(fileName).ToLowerInvariant();
+ var isMatch = detectedContentType switch
+ {
+ "image/png" => ext == ".png",
+ "image/jpeg" => ext == ".jpg" || ext == ".jpeg",
+ "image/webp" => ext == ".webp",
+ "image/gif" => ext == ".gif",
+ _ => false
+ };
+
+ return isMatch;
+ }
}
public record KnowledgeRequest(string Text, Guid? EbookId = null);
diff --git a/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj b/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj
index 9ed3835..7b764a7 100644
--- a/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj
+++ b/tests/NexusReader.Application.Tests/NexusReader.Application.Tests.csproj
@@ -17,5 +17,6 @@
+
diff --git a/tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs b/tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs
index 013b78c..6f69ef4 100644
--- a/tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs
+++ b/tests/NexusReader.Application.Tests/Services/HtmlSanitizerServiceTests.cs
@@ -51,4 +51,20 @@ public class HtmlSanitizerServiceTests
result.Should().NotContain("alert");
result.Should().Contain("
");
}
+
+ [Fact]
+ public void Sanitize_WithMarkdownCodeBlockContainingAngleBrackets_DoesNotStripAngleBrackets()
+ {
+ // Arrange
+ var service = new HtmlSanitizerService();
+ var input = "Here is some code:\n\n```csharp\nif (x < y && y > z) { Console.WriteLine(\"test\"); }\n```";
+
+ // Act
+ var result = service.Sanitize(input);
+
+ // Assert
+ result.Should().Contain("<");
+ result.Should().Contain(">");
+ result.Should().NotContain("